Skip to content

Commit caa454a

Browse files
authored
fix(mothership): bug fixes (#5735)
* fix(mothership): credential connect names, highlighted line, knowledge of connection * fix(mothership): webhook url is now visible to the mothership * fix(mship): tool name audit * fix(mship): hosted key data * fix(mship): show icons on question exed out * fix(mship): redis replay * fix(mothership): description and incremental vfs both nuked * fix(ci): fix build * fix(mship): tool names * fix(mship): stream handling
1 parent f1deb31 commit caa454a

52 files changed

Lines changed: 2084 additions & 415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/mothership/chats/[chatId]/route.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe('GET /api/mothership/chats/[chatId]', () => {
173173
expect(mockReadEvents).not.toHaveBeenCalled()
174174
})
175175

176-
it('returns the live activeStreamId when redis confirms the lock', async () => {
176+
it('returns the live activeStreamId with a status-only snapshot (no events)', async () => {
177177
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
178178
id: 'chat-live',
179179
type: 'mothership',
@@ -185,15 +185,57 @@ describe('GET /api/mothership/chats/[chatId]', () => {
185185
updatedAt: new Date('2026-05-11T12:00:00Z'),
186186
})
187187
mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' })
188+
const previewSession = {
189+
id: 'preview-1',
190+
previewVersion: 1,
191+
status: 'active',
192+
updatedAt: '2026-05-11T12:00:00Z',
193+
}
194+
mockReadFilePreviewSessions.mockResolvedValueOnce([previewSession])
188195

189196
const response = await GET(createRequest('chat-live'), makeContext('chat-live'))
190197
expect(response.status).toBe(200)
191198
const body = await response.json()
192199

193200
expect(body.chat.activeStreamId).toBe('stream-live')
201+
// Events are read only to synthesize the in-flight assistant turn for the
202+
// initial paint; the client reconnects to the replay buffer for the rest.
203+
// Status and preview sessions ARE shipped so hydration can gate the
204+
// reconnect and seed the preview panel before the resume request lands.
194205
expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0')
195-
expect(body.chat.streamSnapshot).toBeDefined()
196-
expect(body.chat.streamSnapshot.status).toBe('active')
206+
expect(mockReadFilePreviewSessions).toHaveBeenCalledWith('stream-live')
207+
expect(body.chat.streamSnapshot).toEqual({
208+
events: [],
209+
previewSessions: [previewSession],
210+
status: 'active',
211+
})
212+
})
213+
214+
it('reports a terminal run status when the stream lock is still visible', async () => {
215+
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
216+
id: 'chat-finished',
217+
type: 'mothership',
218+
title: 'Finished',
219+
messages: [],
220+
resources: [],
221+
conversationId: 'stream-finished',
222+
createdAt: new Date('2026-05-11T12:00:00Z'),
223+
updatedAt: new Date('2026-05-11T12:00:00Z'),
224+
})
225+
mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'complete' })
226+
227+
const response = await GET(createRequest('chat-finished'), makeContext('chat-finished'))
228+
expect(response.status).toBe(200)
229+
const body = await response.json()
230+
231+
// The run finished but the Redis lock hasn't cleared yet: the client
232+
// must see the terminal status so it skips the reconnect entirely.
233+
expect(body.chat.activeStreamId).toBe('stream-finished')
234+
expect(body.chat.streamSnapshot).toEqual({
235+
events: [],
236+
previewSessions: [],
237+
status: 'complete',
238+
})
197239
})
198240

199241
it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => {

apps/sim/app/api/mothership/chats/[chatId]/route.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ export const GET = withRouteHandler(
5050
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
5151
}
5252

53-
let streamSnapshot: {
53+
// The Redis replay buffer is read here only to synthesize the in-flight
54+
// assistant turn for the initial paint. The raw events are NOT shipped
55+
// to the client: when `activeStreamId` is set, the client reconnects to
56+
// the replay buffer (from seq 0) via the stream resume endpoint, which
57+
// is the source of truth for streaming state.
58+
let liveTurnSnapshot: {
5459
events: StreamBatchEvent[]
5560
previewSessions: FilePreviewSession[]
5661
status: string
@@ -84,7 +89,7 @@ export const GET = withRouteHandler(
8489
return null
8590
})
8691

87-
streamSnapshot = {
92+
liveTurnSnapshot = {
8893
events: events.map(toStreamBatchEvent),
8994
previewSessions,
9095
status:
@@ -111,7 +116,7 @@ export const GET = withRouteHandler(
111116
const effectiveMessages = buildEffectiveChatTranscript({
112117
messages: normalizedMessages,
113118
activeStreamId: liveStreamId,
114-
...(streamSnapshot ? { streamSnapshot } : {}),
119+
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
115120
})
116121

117122
return NextResponse.json({
@@ -124,7 +129,19 @@ export const GET = withRouteHandler(
124129
resources: Array.isArray(chat.resources) ? chat.resources : [],
125130
createdAt: chat.createdAt,
126131
updatedAt: chat.updatedAt,
127-
...(streamSnapshot ? { streamSnapshot } : {}),
132+
// Events stay out of the payload (the resume endpoint replays them),
133+
// but the client still needs the run status to skip reconnecting to
134+
// an already-terminal stream, and the preview sessions to seed the
135+
// file preview panel before the reconnect lands.
136+
...(liveTurnSnapshot
137+
? {
138+
streamSnapshot: {
139+
events: [],
140+
previewSessions: liveTurnSnapshot.previewSessions,
141+
status: liveTurnSnapshot.status,
142+
},
143+
}
144+
: {}),
128145
},
129146
})
130147
} catch (error) {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ describe('divider Backspace', () => {
166166
expect(selection.to).toBe(doc.content.size)
167167
editor.destroy()
168168
})
169+
170+
it('only paints a divider inside a range selection while the editor has focus', () => {
171+
const editor = editorWith('<p>a</p><hr><p>b</p>')
172+
const divider = editor.view.dom.querySelector('hr')
173+
editor.commands.selectAll()
174+
175+
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false)
176+
177+
editor.view.dom.dispatchEvent(new FocusEvent('focus'))
178+
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(true)
179+
180+
editor.view.dom.dispatchEvent(new FocusEvent('blur'))
181+
expect(editor.state.selection.empty).toBe(false)
182+
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false)
183+
editor.destroy()
184+
})
169185
})
170186

171187
describe('empty wrapped-block Backspace', () => {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
2020
/** Item node types a list is built from, used to detect an empty item's position within its list. */
2121
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
2222

23+
const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey<boolean>('richLeafSelectionFocus')
24+
2325
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
2426
function isInsideWrapper($from: ResolvedPos): boolean {
2527
for (let depth = $from.depth - 1; depth >= 1; depth--) {
@@ -157,10 +159,11 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
157159
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
158160
* in `./block-mover.ts`.)
159161
*
160-
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
161-
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
162-
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its
163-
* otherwise-stray caret.
162+
* Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g.
163+
* select-all), which the browser's native text highlight skips because leaves carry no text; hiding
164+
* that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the
165+
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide
166+
* its otherwise-stray caret.
164167
*/
165168
export const RichMarkdownKeymap = Extension.create({
166169
name: 'richMarkdownKeymap',
@@ -231,11 +234,34 @@ export const RichMarkdownKeymap = Extension.create({
231234
addProseMirrorPlugins() {
232235
return [
233236
new Plugin({
234-
key: new PluginKey('richLeafSelectionHighlight'),
237+
key: RICH_LEAF_SELECTION_FOCUS_KEY,
238+
state: {
239+
init: () => false,
240+
apply(transaction, focused) {
241+
const nextFocused = transaction.getMeta(RICH_LEAF_SELECTION_FOCUS_KEY)
242+
return typeof nextFocused === 'boolean' ? nextFocused : focused
243+
},
244+
},
235245
props: {
246+
handleDOMEvents: {
247+
focus(view) {
248+
view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, true))
249+
return false
250+
},
251+
blur(view) {
252+
view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, false))
253+
return false
254+
},
255+
},
236256
decorations(state) {
237257
const { selection } = state
238-
if (selection.empty || selection instanceof NodeSelection) return null
258+
if (
259+
RICH_LEAF_SELECTION_FOCUS_KEY.getState(state) !== true ||
260+
selection.empty ||
261+
selection instanceof NodeSelection
262+
) {
263+
return null
264+
}
239265
const decorations: Decoration[] = []
240266
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
241267
if (SELECTABLE_LEAVES.has(node.type.name)) {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ describe('ToolCallItem', () => {
4141
expect(markup).not.toContain('Writing brief.md')
4242
})
4343

44+
it('defensively applies the completed verb for every successful tool row', () => {
45+
const markup = renderToStaticMarkup(
46+
<ToolCallItem toolName='diff_workflows' displayTitle='Comparing workflows' status='success' />
47+
)
48+
49+
expect(markup).toContain('Compared workflows')
50+
expect(markup).not.toContain('Comparing workflows')
51+
})
52+
4453
it('renders the owning integration icon for a resolved integration operation', () => {
4554
vi.mocked(getBlockByToolName).mockReturnValueOnce({
4655
name: 'Gmail',

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@/lib/copilot/generated/tool-catalog-v1'
88
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
99
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
10-
import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display'
10+
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
1111
import { getBareIconStyle } from '@/blocks/icon-color'
1212
import { getBlockByToolName } from '@/blocks/registry'
1313
import type { ToolCallStatus } from '../../../../types'
@@ -44,6 +44,8 @@ interface ToolCallItemProps {
4444
* rewrite in `toToolData`, the past-tense flip is applied here on success.
4545
* A `read` of a block or integration schema shows the block's brand icon
4646
* inline next to its display name (e.g. the Gmail logo before "Read Gmail").
47+
* The status-aware rewrite is repeated at this final rendering boundary so
48+
* live, replayed, and directly-constructed rows cannot bypass completed verbs.
4749
*/
4850
export function ToolCallItem({
4951
toolName,
@@ -98,10 +100,7 @@ export function ToolCallItem({
98100

99101
const isExecuting = resolveToolDisplayState(status) === 'spinner'
100102
const liveTitle = liveWorkspaceFileTitle || displayTitle
101-
const title =
102-
status === 'success' && liveWorkspaceFileTitle
103-
? (getToolCompletedTitle(liveTitle) ?? liveTitle)
104-
: liveTitle
103+
const title = getToolStatusDisplayTitle(liveTitle, status)
105104

106105
const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon
107106

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ interface ChatContentProps {
363363
/** Transcript-derived answers for this message's question card (renders the recap). */
364364
questionAnswers?: string[]
365365
onOptionSelect?: (id: string) => void
366+
onQuestionDismiss?: () => void
366367
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
367368
onRevealStateChange?: (isRevealing: boolean) => void
368369
/** Reports whether this segment is actively painting text or its own pending-tag indicator. */
@@ -374,6 +375,7 @@ function ChatContentInner({
374375
isStreaming = false,
375376
questionAnswers,
376377
onOptionSelect,
378+
onQuestionDismiss,
377379
onWorkspaceResourceSelect,
378380
onRevealStateChange,
379381
onStreamActivityChange,
@@ -586,6 +588,7 @@ function ChatContentInner({
586588
segment={group.segment}
587589
questionAnswers={questionAnswers}
588590
onOptionSelect={onOptionSelect}
591+
onQuestionDismiss={onQuestionDismiss}
589592
/>
590593
)
591594
})}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { act, createElement } from 'react'
55
import { createRoot } from 'react-dom/client'
6-
import { describe, expect, it } from 'vitest'
6+
import { describe, expect, it, vi } from 'vitest'
77
import {
88
formatQuestionAnswerMessage,
99
parseQuestionAnswerMessage,
@@ -89,6 +89,37 @@ describe('parseQuestionAnswerMessage', () => {
8989
})
9090

9191
describe('QuestionDisplay', () => {
92+
it('reports dismissal when the X hides the card', () => {
93+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
94+
const container = document.createElement('div')
95+
document.body.appendChild(container)
96+
const root = createRoot(container)
97+
const onDismiss = vi.fn()
98+
99+
act(() => {
100+
root.render(
101+
createElement(QuestionDisplay, {
102+
data: [QUESTIONS[0]],
103+
onSelect: () => undefined,
104+
onDismiss,
105+
})
106+
)
107+
})
108+
109+
const dismissButton = Array.from(container.querySelectorAll('button')).find((button) =>
110+
button.textContent?.includes('Dismiss')
111+
)
112+
expect(dismissButton).toBeDefined()
113+
114+
act(() => dismissButton?.click())
115+
116+
expect(onDismiss).toHaveBeenCalledOnce()
117+
expect(container.textContent).not.toContain(QUESTIONS[0].prompt)
118+
119+
act(() => root.unmount())
120+
container.remove()
121+
})
122+
92123
it('renders multi-select recap answers as separate, spaced rows', () => {
93124
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
94125
const container = document.createElement('div')

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ interface QuestionDisplayProps {
8686
answers?: string[]
8787
/** Sends the combined answer as a user message; undefined renders the div inert. */
8888
onSelect?: (message: string) => void
89+
/** Reports that the active card was dismissed so its message actions can return. */
90+
onDismiss?: () => void
8991
}
9092

9193
/**
@@ -103,6 +105,7 @@ export function QuestionDisplay({
103105
data,
104106
answers: transcriptAnswers,
105107
onSelect,
108+
onDismiss,
106109
}: QuestionDisplayProps) {
107110
const freeTextInputRef = useRef<HTMLInputElement>(null)
108111
const freeTextCheckboxRef = useRef<HTMLButtonElement>(null)
@@ -292,7 +295,10 @@ export function QuestionDisplay({
292295
<Button
293296
type='button'
294297
variant='ghost'
295-
onClick={() => setPhase('dismissed')}
298+
onClick={() => {
299+
setPhase('dismissed')
300+
onDismiss?.()
301+
}}
296302
className={cn(
297303
ICON_BUTTON_CLASSES,
298304
'before:absolute before:inset-[-14px] before:content-[""]'

0 commit comments

Comments
 (0)