diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index df4e2f41b13..0879cf10aa6 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -173,7 +173,7 @@ describe('GET /api/mothership/chats/[chatId]', () => { expect(mockReadEvents).not.toHaveBeenCalled() }) - it('returns the live activeStreamId when redis confirms the lock', async () => { + it('returns the live activeStreamId with a status-only snapshot (no events)', async () => { mockGetAccessibleCopilotChat.mockResolvedValueOnce({ id: 'chat-live', type: 'mothership', @@ -185,15 +185,57 @@ describe('GET /api/mothership/chats/[chatId]', () => { updatedAt: new Date('2026-05-11T12:00:00Z'), }) mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' }) + const previewSession = { + id: 'preview-1', + previewVersion: 1, + status: 'active', + updatedAt: '2026-05-11T12:00:00Z', + } + mockReadFilePreviewSessions.mockResolvedValueOnce([previewSession]) const response = await GET(createRequest('chat-live'), makeContext('chat-live')) expect(response.status).toBe(200) const body = await response.json() expect(body.chat.activeStreamId).toBe('stream-live') + // Events are read only to synthesize the in-flight assistant turn for the + // initial paint; the client reconnects to the replay buffer for the rest. + // Status and preview sessions ARE shipped so hydration can gate the + // reconnect and seed the preview panel before the resume request lands. expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0') - expect(body.chat.streamSnapshot).toBeDefined() - expect(body.chat.streamSnapshot.status).toBe('active') + expect(mockReadFilePreviewSessions).toHaveBeenCalledWith('stream-live') + expect(body.chat.streamSnapshot).toEqual({ + events: [], + previewSessions: [previewSession], + status: 'active', + }) + }) + + it('reports a terminal run status when the stream lock is still visible', async () => { + mockGetAccessibleCopilotChat.mockResolvedValueOnce({ + id: 'chat-finished', + type: 'mothership', + title: 'Finished', + messages: [], + resources: [], + conversationId: 'stream-finished', + createdAt: new Date('2026-05-11T12:00:00Z'), + updatedAt: new Date('2026-05-11T12:00:00Z'), + }) + mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'complete' }) + + const response = await GET(createRequest('chat-finished'), makeContext('chat-finished')) + expect(response.status).toBe(200) + const body = await response.json() + + // The run finished but the Redis lock hasn't cleared yet: the client + // must see the terminal status so it skips the reconnect entirely. + expect(body.chat.activeStreamId).toBe('stream-finished') + expect(body.chat.streamSnapshot).toEqual({ + events: [], + previewSessions: [], + status: 'complete', + }) }) it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 2274a9582c7..99176cd6b23 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -50,7 +50,12 @@ export const GET = withRouteHandler( return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } - let streamSnapshot: { + // The Redis replay buffer is read here only to synthesize the in-flight + // assistant turn for the initial paint. The raw events are NOT shipped + // to the client: when `activeStreamId` is set, the client reconnects to + // the replay buffer (from seq 0) via the stream resume endpoint, which + // is the source of truth for streaming state. + let liveTurnSnapshot: { events: StreamBatchEvent[] previewSessions: FilePreviewSession[] status: string @@ -84,7 +89,7 @@ export const GET = withRouteHandler( return null }) - streamSnapshot = { + liveTurnSnapshot = { events: events.map(toStreamBatchEvent), previewSessions, status: @@ -111,7 +116,7 @@ export const GET = withRouteHandler( const effectiveMessages = buildEffectiveChatTranscript({ messages: normalizedMessages, activeStreamId: liveStreamId, - ...(streamSnapshot ? { streamSnapshot } : {}), + ...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}), }) return NextResponse.json({ @@ -124,7 +129,19 @@ export const GET = withRouteHandler( resources: Array.isArray(chat.resources) ? chat.resources : [], createdAt: chat.createdAt, updatedAt: chat.updatedAt, - ...(streamSnapshot ? { streamSnapshot } : {}), + // Events stay out of the payload (the resume endpoint replays them), + // but the client still needs the run status to skip reconnecting to + // an already-terminal stream, and the preview sessions to seed the + // file preview panel before the reconnect lands. + ...(liveTurnSnapshot + ? { + streamSnapshot: { + events: [], + previewSessions: liveTurnSnapshot.previewSessions, + status: liveTurnSnapshot.status, + }, + } + : {}), }, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index b9392de02c8..586f2bc2ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -166,6 +166,22 @@ describe('divider Backspace', () => { expect(selection.to).toBe(doc.content.size) editor.destroy() }) + + it('only paints a divider inside a range selection while the editor has focus', () => { + const editor = editorWith('

a


b

') + const divider = editor.view.dom.querySelector('hr') + editor.commands.selectAll() + + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) + + editor.view.dom.dispatchEvent(new FocusEvent('focus')) + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(true) + + editor.view.dom.dispatchEvent(new FocusEvent('blur')) + expect(editor.state.selection.empty).toBe(false) + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) + editor.destroy() + }) }) describe('empty wrapped-block Backspace', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 0d5bc22801c..bdbac57bfc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -20,6 +20,8 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) /** Item node types a list is built from, used to detect an empty item's position within its list. */ const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) +const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey('richLeafSelectionFocus') + /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ function isInsideWrapper($from: ResolvedPos): boolean { for (let depth = $from.depth - 1; depth >= 1; depth--) { @@ -157,10 +159,11 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately * in `./block-mover.ts`.) * - * Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all), - * which the browser's native text highlight skips because leaves carry no text, and (b) flags the - * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its - * otherwise-stray caret. + * Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g. + * select-all), which the browser's native text highlight skips because leaves carry no text; hiding + * that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the + * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide + * its otherwise-stray caret. */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -231,11 +234,34 @@ export const RichMarkdownKeymap = Extension.create({ addProseMirrorPlugins() { return [ new Plugin({ - key: new PluginKey('richLeafSelectionHighlight'), + key: RICH_LEAF_SELECTION_FOCUS_KEY, + state: { + init: () => false, + apply(transaction, focused) { + const nextFocused = transaction.getMeta(RICH_LEAF_SELECTION_FOCUS_KEY) + return typeof nextFocused === 'boolean' ? nextFocused : focused + }, + }, props: { + handleDOMEvents: { + focus(view) { + view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, true)) + return false + }, + blur(view) { + view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, false)) + return false + }, + }, decorations(state) { const { selection } = state - if (selection.empty || selection instanceof NodeSelection) return null + if ( + RICH_LEAF_SELECTION_FOCUS_KEY.getState(state) !== true || + selection.empty || + selection instanceof NodeSelection + ) { + return null + } const decorations: Decoration[] = [] state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { if (SELECTABLE_LEAVES.has(node.type.name)) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index 2fa6cb4c3ee..b432e67efc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -41,6 +41,15 @@ describe('ToolCallItem', () => { expect(markup).not.toContain('Writing brief.md') }) + it('defensively applies the completed verb for every successful tool row', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Compared workflows') + expect(markup).not.toContain('Comparing workflows') + }) + it('renders the owning integration icon for a resolved integration operation', () => { vi.mocked(getBlockByToolName).mockReturnValueOnce({ name: 'Gmail', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 76be63466f6..2b94fcff9b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -7,7 +7,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display' +import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' import { getBareIconStyle } from '@/blocks/icon-color' import { getBlockByToolName } from '@/blocks/registry' import type { ToolCallStatus } from '../../../../types' @@ -44,6 +44,8 @@ interface ToolCallItemProps { * rewrite in `toToolData`, the past-tense flip is applied here on success. * A `read` of a block or integration schema shows the block's brand icon * inline next to its display name (e.g. the Gmail logo before "Read Gmail"). + * The status-aware rewrite is repeated at this final rendering boundary so + * live, replayed, and directly-constructed rows cannot bypass completed verbs. */ export function ToolCallItem({ toolName, @@ -98,10 +100,7 @@ export function ToolCallItem({ const isExecuting = resolveToolDisplayState(status) === 'spinner' const liveTitle = liveWorkspaceFileTitle || displayTitle - const title = - status === 'success' && liveWorkspaceFileTitle - ? (getToolCompletedTitle(liveTitle) ?? liveTitle) - : liveTitle + const title = getToolStatusDisplayTitle(liveTitle, status) const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 580483bc8b3..489eb3e1c62 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -347,6 +347,7 @@ interface ChatContentProps { /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] onOptionSelect?: (id: string) => void + onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void /** Reports whether this segment is actively painting text or its own pending-tag indicator. */ @@ -358,6 +359,7 @@ function ChatContentInner({ isStreaming = false, questionAnswers, onOptionSelect, + onQuestionDismiss, onWorkspaceResourceSelect, onRevealStateChange, onStreamActivityChange, @@ -570,6 +572,7 @@ function ChatContentInner({ segment={group.segment} questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} + onQuestionDismiss={onQuestionDismiss} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index e3761f3d0e7..c22bc12ac5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -3,7 +3,7 @@ */ import { act, createElement } from 'react' import { createRoot } from 'react-dom/client' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { formatQuestionAnswerMessage, parseQuestionAnswerMessage, @@ -89,6 +89,37 @@ describe('parseQuestionAnswerMessage', () => { }) describe('QuestionDisplay', () => { + it('reports dismissal when the X hides the card', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onDismiss = vi.fn() + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[0]], + onSelect: () => undefined, + onDismiss, + }) + ) + }) + + const dismissButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Dismiss') + ) + expect(dismissButton).toBeDefined() + + act(() => dismissButton?.click()) + + expect(onDismiss).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain(QUESTIONS[0].prompt) + + act(() => root.unmount()) + container.remove() + }) + it('renders multi-select recap answers as separate, spaced rows', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 9475f0fd4da..53811b94c04 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -86,6 +86,8 @@ interface QuestionDisplayProps { answers?: string[] /** Sends the combined answer as a user message; undefined renders the div inert. */ onSelect?: (message: string) => void + /** Reports that the active card was dismissed so its message actions can return. */ + onDismiss?: () => void } /** @@ -103,6 +105,7 @@ export function QuestionDisplay({ data, answers: transcriptAnswers, onSelect, + onDismiss, }: QuestionDisplayProps) { const freeTextInputRef = useRef(null) const freeTextCheckboxRef = useRef(null) @@ -292,7 +295,10 @@ export function QuestionDisplay({