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
48 changes: 45 additions & 3 deletions apps/sim/app/api/mothership/chats/[chatId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
25 changes: 21 additions & 4 deletions apps/sim/app/api/mothership/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,7 +89,7 @@ export const GET = withRouteHandler(
return null
})

streamSnapshot = {
liveTurnSnapshot = {
Comment thread
Sg312 marked this conversation as resolved.
events: events.map(toStreamBatchEvent),
previewSessions,
status:
Expand All @@ -111,7 +116,7 @@ export const GET = withRouteHandler(
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: liveStreamId,
...(streamSnapshot ? { streamSnapshot } : {}),
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
})

return NextResponse.json({
Expand All @@ -124,7 +129,19 @@ export const GET = withRouteHandler(
resources: Array.isArray(chat.resources) ? chat.resources : [],
Comment thread
Sg312 marked this conversation as resolved.
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>a</p><hr><p>b</p>')
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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>('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--) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ToolCallItem toolName='diff_workflows' displayTitle='Comparing workflows' status='success' />
)

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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -358,6 +359,7 @@ function ChatContentInner({
isStreaming = false,
questionAnswers,
onOptionSelect,
onQuestionDismiss,
onWorkspaceResourceSelect,
onRevealStateChange,
onStreamActivityChange,
Expand Down Expand Up @@ -570,6 +572,7 @@ function ChatContentInner({
segment={group.segment}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
/>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -103,6 +105,7 @@ export function QuestionDisplay({
data,
answers: transcriptAnswers,
onSelect,
onDismiss,
}: QuestionDisplayProps) {
const freeTextInputRef = useRef<HTMLInputElement>(null)
const freeTextCheckboxRef = useRef<HTMLButtonElement>(null)
Expand Down Expand Up @@ -292,7 +295,10 @@ export function QuestionDisplay({
<Button
type='button'
variant='ghost'
onClick={() => setPhase('dismissed')}
onClick={() => {
setPhase('dismissed')
onDismiss?.()
}}
className={cn(
ICON_BUTTON_CLASSES,
'before:absolute before:inset-[-14px] before:content-[""]'
Expand Down
Loading
Loading