From a346bcaa55acfc7b3a17d95e463a1a8c62a88e6a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:49:19 -0800 Subject: [PATCH 1/3] improvement(chat): show background task and scheduled job turns as messages --- .../watch-activity/watch-activity.tsx | 3 +- .../mothership-chat/mothership-chat.tsx | 58 +++++++---- .../mothership/chat/automated-turn.test.ts | 95 +++++++++++++++++++ .../sim/lib/mothership/chat/automated-turn.ts | 58 +++++++++++ 4 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 apps/sim/lib/mothership/chat/automated-turn.test.ts create mode 100644 apps/sim/lib/mothership/chat/automated-turn.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity.tsx index dc833b8df6e..540932f8500 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity.tsx @@ -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 = diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 2eca1fc5feb..6c1a85a72c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -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' @@ -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, @@ -204,37 +208,54 @@ 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 }>() + const { label, text, note } = describeAutomatedTurn(origin, content, params?.workspaceId) + const Icon = origin === 'task' ? Clock : Calendar return ( -
-
- - {origin === 'task' ? 'Background task' : 'Scheduled job'} - - {summary} +
+
+ + {label} +
+
+
) @@ -995,7 +1016,8 @@ export function MothershipChat({ ) : ( { + 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() + }) +}) diff --git a/apps/sim/lib/mothership/chat/automated-turn.ts b/apps/sim/lib/mothership/chat/automated-turn.ts new file mode 100644 index 00000000000..0dd0e109723 --- /dev/null +++ b/apps/sim/lib/mothership/chat/automated-turn.ts @@ -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 () : `. */ +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 | 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, + summary: match[4], + }, + } +} From 46442b7c6af93473b8aa39d5295ebf69e4b78725 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 17:59:57 -0800 Subject: [PATCH 2/3] fix(chat): refresh automated turn workflow names and use shared label styles --- .../mothership-chat/mothership-chat.tsx | 6 ++-- .../home/hooks/use-tool-resource-titles.ts | 33 ++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 6c1a85a72c7..bd01970982c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -66,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, @@ -246,12 +247,13 @@ const AutomatedTurnRow = memo(function AutomatedTurnRow({ bubbleClassName, }: AutomatedTurnRowProps) { const params = useParams<{ workspaceId?: string }>() + useResourceNameRevision() const { label, text, note } = describeAutomatedTurn(origin, content, params?.workspaceId) const Icon = origin === 'task' ? Clock : Calendar return (
-
- +
+ {label}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts index 3be379bf512..6fffdcacc21 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-tool-resource-titles.ts @@ -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( () => From df14fdf8f57065782c312ec62940fc2c9bec299a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 18:09:54 -0800 Subject: [PATCH 3/3] chore(chat): shrink the text-size-literal baseline --- scripts/check-source-conventions.baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index 5b00b8d034f..138033a0538 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -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