From 605b3b6811f94af2a0eb5b8019c0c822b859cf1a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 3 Sep 2026 14:04:08 -0700 Subject: [PATCH] fix(chat): preserve queued edits and render quoted source chips --- .../chat-content/chat-content.test.ts | 41 ++- .../components/chat-content/chat-sanitize.ts | 107 ++++---- .../suggested-actions.test.tsx | 5 +- .../mode-switcher/mode-switcher.test.tsx | 84 +++--- .../mode-switcher/mode-switcher.tsx | 20 +- .../components/user-input/user-input.test.tsx | 241 ++++++++++++++++++ .../home/components/user-input/user-input.tsx | 11 +- .../app/workspace/[workspaceId]/home/home.tsx | 1 - .../home/hooks/use-mothership-mode.ts | 25 +- .../[workspaceId]/home/search-params.ts | 7 + 10 files changed, 423 insertions(+), 119 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts index eb9f86f6035..e8a3e555831 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize' import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers' -import { sanitizeChatDisplayContent } from './chat-sanitize' describe('sanitizeChatDisplayContent', () => { it('unwraps workspace resource tags from inline code spans', () => { @@ -23,6 +23,35 @@ describe('sanitizeChatDisplayContent', () => { ) }) + it.each(['source', 'workspace_resource'])('preserves backticks inside %s JSON strings', (tag) => { + const payload = JSON.stringify({ + title: 'Run `bun test`', + snippet: 'Quoted "commands" and a \\path with `backticks`', + }) + const chip = `<${tag}>${payload}` + + expect(sanitizeChatDisplayContent(`\`Evidence ${chip}.\``)).toBe(`Evidence ${chip}.`) + expect(sanitizeChatDisplayContent(`\`${chip} done`)).toBe(`${chip} done`) + expect(sanitizeChatDisplayContent(`${chip}\` done`)).toBe(`${chip} done`) + expect(sanitizeChatDisplayContent(`\`before\`${chip}\`after\``)).toBe( + `\`before\`${chip}\`after\`` + ) + }) + + it('treats tag markers inside JSON strings as payload', () => { + const payload = JSON.stringify({ snippet: 'Use `` and `` markers' }) + const chip = `${payload}` + + expect(sanitizeChatDisplayContent(`\`See ${chip}\``)).toBe(`See ${chip}`) + }) + + it('leaves a fenced source example with payload backticks intact', () => { + const payload = JSON.stringify({ snippet: 'Run `bun test`' }) + const content = `Example:\n\`\`\`json\n${payload}\n\`\`\`\nDone.` + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + it('removes hidden internal references wrapped in inline code', () => { const content = 'Read `internal/tool-results/read-1.md` and found the issue.' @@ -105,6 +134,16 @@ describe('sanitizeChatDisplayContent', () => { expect(scalingRatioOver4x((content) => sanitizeChatDisplayContent(content))).toBeLessThan(8) }) + it('stays linear on repeated unclosed JSON chip bodies', () => { + expect( + scalingRatioOver4x((content) => + sanitizeChatDisplayContent( + content.replaceAll('The tag is used here. ', '{"snippet":"') + ) + ) + ).toBeLessThan(8) + }) + it('still unwraps a real tag that carries a stray backtick on one side only', () => { // The case the unpaired strip is actually for: the model backticked the // opener but not the closer (or vice versa), which would block the chip. diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts index 1d210bd1b92..867916bd863 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts @@ -1,65 +1,66 @@ const HIDDEN_INLINE_REFERENCE_PATTERN = /`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g +/** JSON strings own their escaped quotes, backticks, and any quoted tag markers. */ +const JSON_STRING_SOURCE = String.raw`"(?:[^"\\\r\n]|\\[^\r\n])*"` + /** - * A complete inline-chip tag — `` or `` — as - * opener, payload, closer. Both are JSON-bodied tags the model places inside a - * sentence, so both attract the same stray backticks. - * - * Two constraints on the payload, both load-bearing: - * - * - **No backtick.** A payload is JSON and carries none, so this is what tells a - * real tag from prose MENTIONING the tag name — a message explaining the - * syntax writes the opener and the closer as two separately backticked spans. - * - **No nested opener**, via the negative lookahead. A cost bound rather than a - * correctness rule: a lazy scan allowed to cross an opener restarts from every - * opener, so a message repeating the tag name is quadratic — on the main - * thread, for every streamed chunk. - * - * Accepted trade: a resource whose title or path itself contains a backtick is - * not matched, so it renders as text rather than a chip. That costs one chip and - * is rare; the failure it replaces corrupts a whole message and is common. + * Complete chip tags consume JSON strings atomically. Outside strings, a new + * opener or backtick ends the candidate, so prose mentions cannot join into a + * tag and repeated unclosed openers cannot repeatedly scan the same suffix. */ -const COMPLETE_TAG_SOURCE = - '<(?workspace_resource|source)>(?:(?!<\\k>)[^`])*?<\\/\\k>' +const COMPLETE_TAG_SOURCE = `<(?workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<])*?\\}\\s*>` -/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */ -const COMPLETE_INLINE_CHIP_TAG = new RegExp(COMPLETE_TAG_SOURCE) +const CHIP_OR_CODE_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`|\n`, 'g') /** - * One left-to-right pass over the two things that can own a backtick: an inline - * code span, and a tag with a stray backtick pressed against it. - * - * ONE pass is the design. Two separate passes each have to guess which backticks - * belong together, and every previous arrangement of this file got a different - * case wrong — a span two words away, a code fence, then a span sitting flush - * against the tag. Here a span consumes its own delimiters as the scan reaches - * them, so `` `config.json` `` keeps its pair without a special case. - * - * The trailing backtick is only taken when no further backtick follows on the - * line; otherwise it is not a stray at all but the opener of the next span, and - * `` `config.json` `` would lose that span's delimiter. A LEADING backtick - * needs no such guard, because a backtick that closes a span is consumed as part - * of that span. Of the two, only the trailing lookahead is pinned by a test — - * swapping the alternatives changes behaviour only for a span that both opens - * flush against a tag and closes elsewhere, which no fixture covers. + * Pair Markdown delimiters outside chip payloads in one forward pass. A pair + * containing a chip is unwrapped; a lone delimiter is removed only when flush + * against a chip. Neighbouring code spans and multiline fences keep their pairs. */ -const CODE_SPAN_OR_FLANKED_TAG = new RegExp( - `\`[^\`\\n]*\`|\`?(${COMPLETE_TAG_SOURCE})(?:\`(?![^\`\\n]*\`))?`, - 'g' -) - export function sanitizeChatDisplayContent(content: string): string { - return content - .replace(CODE_SPAN_OR_FLANKED_TAG, (match, tag?: string) => { - // A tag with stray backticks against it: keep the tag, drop the strays. - if (tag !== undefined) return tag + const removedDelimiters: number[] = [] + let openingTick = -1 + let containsChip = false + let adjacentToChip = false + let lastChipEnd = -1 + + for (const match of content.matchAll(CHIP_OR_CODE_DELIMITER)) { + const index = match.index + if (match.groups?.chipTag) { + if (openingTick !== -1) { + containsChip = true + adjacentToChip ||= index === openingTick + 1 + } + lastChipEnd = index + match[0].length + continue + } + + if (match[0] === '\n') { + if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick) + openingTick = -1 + lastChipEnd = -1 + continue + } + + if (openingTick === -1) { + openingTick = index + containsChip = false + adjacentToChip = lastChipEnd === index + } else { + if (containsChip) removedDelimiters.push(openingTick, index) + openingTick = -1 + } + } + + if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick) - // A code span. Unwrap it only when it genuinely holds a tag — the parser - // lifts the tag out either way, so leaving the delimiters would strand a - // pair of backticks around a hole. Anything else is someone else's span. - const inner = match.slice(1, -1) - return COMPLETE_INLINE_CHIP_TAG.test(inner) ? inner : match - }) - .replace(HIDDEN_INLINE_REFERENCE_PATTERN, '') + const parts: string[] = [] + let start = 0 + for (const index of removedDelimiters) { + parts.push(content.slice(start, index)) + start = index + 1 + } + parts.push(content.slice(start)) + return parts.join('').replace(HIDDEN_INLINE_REFERENCE_PATTERN, '') } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx index a879ee5bc35..af71779f6b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx @@ -7,14 +7,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCaptureEvent, modeState } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn(), - /** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */ modeState: { initial: 'build', set: (_next: string) => {} }, })) -vi.mock('nuqs', async () => { +vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode', async () => { const { useState } = await import('react') return { - useQueryState: () => { + useMothershipMode: () => { const [mode, setMode] = useState(modeState.initial) modeState.set = setMode return [mode, setMode] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx index 92f6aa38b0e..e1a3bc07604 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx @@ -2,34 +2,19 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCaptureEvent, mockSetSearchQuery, mockSetSearchFilters, modeState } = vi.hoisted( - () => ({ - mockCaptureEvent: vi.fn(), - mockSetSearchQuery: vi.fn(), - mockSetSearchFilters: vi.fn(), - /** The URL `mode` param as the nuqs mock serves it; `set` is the live setter once mounted. */ - modeState: { initial: 'build', set: (_next: string) => {} }, - }) -) +const { mockCaptureEvent, mockLeaveSearch } = vi.hoisted(() => ({ + mockCaptureEvent: vi.fn(), + mockLeaveSearch: vi.fn(), +})) +const mockUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>() vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) -vi.mock('nuqs', async () => { - const { useState } = await import('react') - return { - useQueryState: (key: string) => { - const [mode, setMode] = useState(modeState.initial) - if (key !== 'mode') return [null, mockSetSearchQuery] - modeState.set = setMode - return [mode, setMode] - }, - useQueryStates: () => [{}, mockSetSearchFilters], - } -}) vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent })) @@ -38,12 +23,18 @@ import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user let root: Root | null = null let container: HTMLDivElement | null = null -function mount() { +function mount(searchParams = '') { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root?.render()) + act(() => + root?.render( + + + + ) + ) } function trigger(): HTMLButtonElement { @@ -63,17 +54,18 @@ function items(): HTMLElement[] { return Array.from(document.querySelectorAll('[role="menuitem"]')) } -function select(index: number) { - act(() => { +async function select(index: number) { + await act(async () => { items()[index].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) + await vi.advanceTimersByTimeAsync(1) }) } beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) mockCaptureEvent.mockClear() - mockSetSearchQuery.mockClear() - mockSetSearchFilters.mockClear() - modeState.initial = 'build' + mockLeaveSearch.mockClear() + mockUrlUpdate.mockClear() }) afterEach(() => { @@ -81,6 +73,7 @@ afterEach(() => { container?.remove() root = null container = null + vi.useRealTimers() }) describe('ModeSwitcher', () => { @@ -108,47 +101,52 @@ describe('ModeSwitcher', () => { expect(rows[2].querySelector('svg')).toBeNull() }) - it('writes the chosen mode to the URL and reports the change', () => { + it('writes the chosen mode to the URL and reports the change', async () => { mount() openMenu() - select(1) + await select(1) expect(trigger().textContent).toBe('Search') expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', { workspace_id: 'workspace-1', mode: 'search', }) - expect(mockSetSearchQuery).not.toHaveBeenCalled() + expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.get('mode')).toBe('search') + expect(mockLeaveSearch).not.toHaveBeenCalled() }) it('reads the mode from the URL on mount', () => { - modeState.initial = 'assistant' - mount() + mount('?mode=assistant') expect(trigger().textContent).toBe('Assistant') expect(trigger().getAttribute('aria-label')).toBe('Mode: Assistant') }) - it('drops the search query from the URL when leaving Search', () => { - modeState.initial = 'search' - mount() + it('clears the composer and search parameters together when leaving Search', async () => { + mount('?mode=search&q=budget&source=upload&updated=7d&resource=report') openMenu() - select(0) + await select(0) expect(trigger().textContent).toBe('Build') - expect(mockSetSearchQuery).toHaveBeenCalledWith(null, { history: 'replace', scroll: false }) - expect(mockSetSearchFilters).toHaveBeenCalledWith( - { source: null, updated: null }, - { history: 'replace', scroll: false } + expect(mockLeaveSearch).toHaveBeenCalledOnce() + expect(mockUrlUpdate).toHaveBeenCalledOnce() + expect(mockUrlUpdate.mock.lastCall?.[0].searchParams.toString()).toBe('resource=report') + expect(mockUrlUpdate.mock.lastCall?.[0].options).toMatchObject({ + history: 'replace', + scroll: false, + }) + expect(mockLeaveSearch.mock.invocationCallOrder[0]).toBeLessThan( + mockUrlUpdate.mock.invocationCallOrder[0] ) }) - it('does not report re-selecting the active mode', () => { + it('does not report re-selecting the active mode', async () => { mount() openMenu() - select(0) + await select(0) expect(trigger().textContent).toBe('Build') expect(mockCaptureEvent).not.toHaveBeenCalled() + expect(mockLeaveSearch).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx index e7ec9a3b6a8..110248bb2da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx @@ -11,17 +11,12 @@ import { } from '@sim/emcn' import { Check } from '@sim/emcn/icons' import { useParams } from 'next/navigation' -import { useQueryState, useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { useMothershipMode } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-mode' import { - CLEARED_SEARCH_FILTERS, MOTHERSHIP_MODES, type MothershipMode, - resourceUrlKeys, - searchFilterParsers, - searchQueryParam, } from '@/app/workspace/[workspaceId]/home/search-params' const MODE_LABELS: Record = { @@ -30,6 +25,10 @@ const MODE_LABELS: Record = { assistant: 'Assistant', } +interface ModeSwitcherProps { + onLeaveSearch?: () => void +} + /** * The composer's Build / Search / Assistant switcher: a label-only `Chip` in its `round` * shape — chip chrome throughout (`--text-body` label, `--surface-hover` on @@ -37,22 +36,15 @@ const MODE_LABELS: Record = { * round controls — opening a menu that checks the active mode, as * `ChipDropdown` does. */ -export const ModeSwitcher = memo(function ModeSwitcher() { +export const ModeSwitcher = memo(function ModeSwitcher({ onLeaveSearch }: ModeSwitcherProps) { const { workspaceId } = useParams<{ workspaceId: string }>() const posthog = usePostHog() const [mode, setMode] = useMothershipMode() - const [, setSearchQueryParam] = useQueryState(searchQueryParam.key, searchQueryParam.parser) - const [, setSearchFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys) - - /** Leaving Search drops the query from the URL, so a clean URL always means no search is showing. */ const handleSelect = (next: MothershipMode) => { if (next === mode) return + if (mode === 'search' && next !== 'search') onLeaveSearch?.() void setMode(next) - if (next !== 'search') { - void setSearchQueryParam(null, { history: 'replace', scroll: false }) - void setSearchFilters(CLEARED_SEARCH_FILTERS, { history: 'replace', scroll: false }) - } captureEvent(posthog, 'chat_mode_changed', { workspace_id: workspaceId, mode: next }) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx new file mode 100644 index 00000000000..240becccf96 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -0,0 +1,241 @@ +/** + * @vitest-environment jsdom + */ +import { act, createRef, useRef } from 'react' +import { useQueryState } from 'nuqs' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' +import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types' + +const { mockSubmit, mockResetTranscript } = vi.hoisted(() => ({ + mockSubmit: vi.fn(), + mockResetTranscript: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) +vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) +vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() })) +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), +})) +vi.mock('@/hooks/use-speech-to-text', () => ({ + useSpeechToText: () => ({ isSupported: false, resetTranscript: mockResetTranscript }), +})) +vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) })) +vi.mock('@/blocks/integration-matcher', () => ({ + getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), + mentionifyIntegrations: (text: string) => text, +})) +vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({ + useChatSurface: () => ({}), +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments', + async () => { + const { useRef, useState } = await import('react') + return { + useFileAttachments: () => { + const [attachedFiles, restoreAttachedFiles] = useState< + import('@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments').AttachedFile[] + >([]) + return { + attachedFiles, + restoreAttachedFiles, + clearAttachedFiles: () => restoreAttachedFiles([]), + fileInputRef: useRef(null), + isDragging: false, + } + }, + } + } +) +vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', async () => { + const { usePromptEditor } = await import( + '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' + ) + const { ModeSwitcher } = await import( + '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher' + ) + return { + usePromptEditor, + ModeSwitcher, + PromptEditor: ({ editor }: { editor: PromptEditorInstance }) => ( +