isContentHeading(item) && (slug?.[0] !== 'integrations' || item.depth <= 3) )} full={data.full} @@ -359,7 +356,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> className='max-sm:[&>h1]:basis-0 max-sm:[&_a]:size-10' >
- +
diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 17e36be5d4b..84f6a15fc6e 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -4,13 +4,16 @@ import { DOCS_BASE_URL } from '@/lib/urls' export const revalidate = 3600 -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { // The docs root redirects to /introduction, which is listed on its own. - return source - .getPages() - .filter((page) => page.url !== '/') - .map((page) => ({ + const entries: MetadataRoute.Sitemap = [] + for (const page of source.getPages()) { + if (page.url === '/') continue + const data = page.data.type === 'docs' ? await page.data.load() : undefined + entries.push({ url: `${DOCS_BASE_URL}${page.url}`, - lastModified: 'lastModified' in page.data ? page.data.lastModified : undefined, - })) + lastModified: data?.lastModified, + }) + } + return entries } diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index 4ea13bc7417..a632e2cd87c 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -112,7 +112,7 @@ export function SidebarFolder({ item, children }: SidebarFolderProps) { )} {hasChildren && ( )} diff --git a/apps/docs/components/page-actions.tsx b/apps/docs/components/page-actions.tsx index 53c92c8d087..c55fa06e7e9 100644 --- a/apps/docs/components/page-actions.tsx +++ b/apps/docs/components/page-actions.tsx @@ -1,19 +1,68 @@ 'use client' -import { Chip } from '@sim/emcn' +import { useRef, useState } from 'react' +import { Chip, useCopyToClipboard } from '@sim/emcn' import { Check, Duplicate } from '@sim/emcn/icons' -import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button' -export function LLMCopyButton({ content }: { content: string }) { - const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content)) +interface LLMCopyButtonProps { + markdownUrl: string +} + +export function LLMCopyButton({ markdownUrl }: LLMCopyButtonProps) { + const { copied, copy } = useCopyToClipboard() + const [status, setStatus] = useState<'idle' | 'pending' | 'ready' | 'failed'>('idle') + const preparedMarkdown = useRef(null) + const pending = status === 'pending' + const checked = copied && status === 'idle' + + async function onClick() { + setStatus('pending') + const prepare = async () => { + const response = await fetch(markdownUrl, { + signal: typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(10000) : undefined, + }) + if (!response.ok) throw new Error('Could not load page Markdown') + const markdown = await response.text() + if (!markdown.trim()) throw new Error('Page Markdown is empty') + return markdown + } + try { + // Promise-backed writes preserve Safari's transient user activation during the fetch. + if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') { + const success = await copy({ prepare, fallback: '' }) + setStatus(success ? 'idle' : 'failed') + return + } + if (preparedMarkdown.current === null) { + preparedMarkdown.current = await prepare() + setStatus('ready') + return + } + const success = await copy(preparedMarkdown.current) + setStatus(success ? 'idle' : 'failed') + } catch { + setStatus('failed') + } + } return ( - - {checked ? 'Copied' : 'Copy page'} - +
+ + {checked ? 'Copied' : pending ? 'Copying…' : 'Copy page'} + + + {status === 'failed' + ? 'Could not copy. Try again.' + : status === 'ready' + ? 'Click again to copy.' + : ''} + +
) } diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts index 4fee2a7debe..f26468d8908 100644 --- a/apps/docs/lib/source.ts +++ b/apps/docs/lib/source.ts @@ -129,4 +129,5 @@ export type PageData = DocData & description?: string full?: boolean integration?: { type: string; color: string } + load: () => Promise } diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index fd57395c691..8c62df1caee 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -6,7 +6,6 @@ const withMDX = createMDX() const config: NextConfig = { reactStrictMode: true, - productionBrowserSourceMaps: true, transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'], images: { unoptimized: true, diff --git a/apps/docs/package.json b/apps/docs/package.json index 32dc1dee5fc..1191be6c832 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -18,7 +18,8 @@ "lint:check": "biome check .", "format": "biome format --write .", "format:check": "biome format .", - "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts" + "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts", + "test:navigation:e2e": "bun scripts/test-navigation-e2e.ts" }, "dependencies": { "@sim/db": "workspace:*", @@ -46,6 +47,7 @@ "zod": "4.3.6" }, "devDependencies": { + "@playwright/test": "1.61.1", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@types/node": "24.19.2", diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts new file mode 100644 index 00000000000..bec6466ef44 --- /dev/null +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -0,0 +1,278 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { type Browser, chromium, expect } from '@playwright/test' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' + +const logger = createLogger('DocsNavigationE2E', { logLevel: 'INFO' }) +const origin = process.env.DOCS_E2E_BASE_URL +const reportPath = process.env.DOCS_NAVIGATION_REPORT_PATH +if (!origin || !reportPath) { + throw new Error('A docs base URL and DOCS_NAVIGATION_REPORT_PATH are required') +} +const target = new URL(origin) +if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) { + throw new Error('The documentation server must use a loopback origin') +} + +const pages = [ + { path: '/introduction', title: 'Introduction', content: '## Models and integrations' }, + { path: '/getting-started', title: 'Getting started', content: '## Start in Chat' }, + { path: '/files', title: 'Files', content: '## Upload or create a file' }, + { path: '/integrations/apollo', title: 'Apollo', content: '### Apollo People Search' }, + { + path: '/api-reference/workflows/listWorkflows', + title: 'List workflows', + content: '`GET /api/v2/workflows`', + }, +] as const +const viewports = [ + { name: 'desktop', width: 1440, height: 1000 }, + { name: 'mobile', width: 390, height: 844 }, +] as const +const results: Record[] = [] +let failed = false +await mkdir(dirname(reportPath), { recursive: true }) +let browser: Browser | undefined +try { + browser = await chromium.launch() + for (const viewport of viewports) { + for (const { path, title, content } of pages) { + const started = performance.now() + const context = await browser.newContext({ viewport, reducedMotion: 'reduce' }) + const page = await context.newPage() + const errors: string[] = [] + const markdownRequests: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('request', (request) => { + if (new URL(request.url()).pathname.startsWith('/llms.mdx')) { + markdownRequests.push(request.url()) + } + }) + try { + const response = await page.goto(new URL(path, origin).toString(), { + waitUntil: 'networkidle', + }) + if (!response?.ok()) throw new Error(`Page returned ${response?.status() ?? 'no response'}`) + await page.getByRole('heading', { level: 1 }).waitFor() + await page.evaluate(async () => { + await document.fonts.ready + }) + if (path.startsWith('/api-reference/')) { + const tabs = page.getByRole('tablist', { name: 'Request language' }).getByRole('tab') + const count = await tabs.count() + if (count < 2) throw new Error('API request-language controls are missing') + const selectedIndex = await tabs.evaluateAll((elements) => + elements.findIndex((element) => element.getAttribute('aria-selected') === 'true') + ) + if (selectedIndex < 0) throw new Error('API request language has no selected tab') + for (let offset = 1; offset <= count; offset += 1) { + const tab = tabs.nth((selectedIndex + offset) % count) + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') + await page.waitForFunction(() => { + const selected = document.querySelector( + '[aria-label="Request language"] [aria-selected="true"]' + ) + const panelId = selected?.getAttribute('aria-controls') + const panel = panelId ? document.getElementById(panelId) : null + return Boolean( + panel && + panel.getBoundingClientRect().height > 0 && + panel.querySelector('code')?.textContent?.trim() + ) + }) + } + } + if (viewport.name === 'desktop') { + const markdownUrl = new URL(`/llms.mdx${path}`, origin).toString() + const markdown = await page.request.get(markdownUrl) + expect(markdown.ok()).toBe(true) + const expected = await markdown.text() + expect(expected).toContain(`# ${title} (${path})`) + expect(expected).toContain(content) + if (path.startsWith('/api-reference/')) expect(expected).toContain('## Responses') + const copy = page.getByRole('button', { name: 'Copy page content', exact: true }) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + expect(markdownRequests).toEqual([]) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + expect(markdownRequests).toEqual([markdownUrl]) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(expected) + for (const response of [ + { status: 503, body: 'Unavailable' }, + { status: 200, body: ' \n ' }, + ]) { + await page.route(markdownUrl, (route) => route.fulfill(response)) + await page.getByRole('button', { name: 'Copied to clipboard' }).click() + await expect(page.getByRole('status')).toHaveText('Could not copy. Try again.') + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toHaveCount(0) + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(expected) + await page.unroute(markdownUrl) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + } + } + await page.screenshot({ + path: join(dirname(reportPath), `${viewport.name}${path.replaceAll('/', '-')}.png`), + }) + if (path === '/introduction') { + const mobile = viewport.name === 'mobile' + if (mobile) await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click() + const navigation = page.getByRole(mobile ? 'dialog' : 'complementary', { + name: 'Documentation navigation', + }) + const platform = navigation.getByRole('button', { name: 'Platform', exact: true }) + const costs = navigation.getByRole('link', { + name: 'Credits, billing and limits', + exact: true, + }) + await platform.click() + await expect(costs).toBeVisible() + await platform.press('Enter') + await expect(costs).toBeHidden() + await platform.press('Enter') + await costs.click() + await expect(page).toHaveURL(new URL('/platform/costs', origin).toString()) + await expect( + page.getByRole('heading', { + level: 1, + name: 'Credits, billing and limits', + exact: true, + }) + ).toBeVisible() + if (mobile) { + await expect(navigation).not.toBeVisible() + await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click() + } + await expect(costs).toHaveAttribute('aria-current', 'page') + await expect(platform).toHaveAttribute('aria-expanded', 'true') + if (!mobile) { + const markdown = await page.request.get( + new URL('/llms.mdx/platform/costs', origin).toString() + ) + expect(markdown.ok()).toBe(true) + const expected = await markdown.text() + expect(expected).toContain('# Credits, billing and limits (/platform/costs)') + expect(expected).toContain('## What a workflow run costs') + await page.getByRole('button', { name: 'Copy page content', exact: true }).click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(expected) + } + } + results.push({ + path, + viewport: viewport.name, + status: errors.length ? 'failed' : 'passed', + durationMs: Math.round(performance.now() - started), + errors, + }) + if (errors.length) failed = true + } catch (error) { + failed = true + results.push({ + path, + viewport: viewport.name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + errors, + }) + } finally { + await context.close() + } + } + } + const started = performance.now() + const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }) + try { + await context.addInitScript(() => { + Object.defineProperty(window, 'ClipboardItem', { value: undefined }) + const writeText = navigator.clipboard.writeText.bind(navigator.clipboard) + navigator.clipboard.writeText = (text) => { + document.documentElement.dataset.clipboardActivation = String( + navigator.userActivation.isActive + ) + return writeText(text) + } + }) + const page = await context.newPage() + const markdownUrl = new URL('/llms.mdx/introduction', origin).toString() + const requests: string[] = [] + await page.route('**/llms.mdx/**', async (route) => { + requests.push(route.request().url()) + await page.waitForFunction(() => !navigator.userActivation.isActive) + await route.continue() + }) + await page.goto(new URL('/introduction', origin).toString(), { waitUntil: 'networkidle' }) + const copy = page.getByRole('button', { name: /^(Copy page content|Copied to clipboard)$/ }) + const loaded = page.waitForResponse(markdownUrl) + await copy.click() + await (await loaded).finished() + await expect(copy).toBeEnabled() + await expect(page.locator('html')).not.toHaveAttribute('data-clipboard-activation') + await page.screenshot({ path: join(dirname(reportPath), 'legacy-clipboard-ready.png') }) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + await expect(page.locator('html')).toHaveAttribute('data-clipboard-activation', 'true') + expect(requests).toEqual([markdownUrl]) + const copied = await page.evaluate(() => navigator.clipboard.readText()) + expect(copied).toContain('# Introduction (/introduction)') + expect(copied).toContain('## Models and integrations') + await page.screenshot({ path: join(dirname(reportPath), 'legacy-clipboard.png') }) + const navigation = page.getByRole('complementary', { name: 'Documentation navigation' }) + await navigation.getByRole('button', { name: 'Platform', exact: true }).click() + await navigation.getByRole('link', { name: 'Credits, billing and limits', exact: true }).click() + await expect(page).toHaveURL(new URL('/platform/costs', origin).toString()) + await expect( + page.getByRole('heading', { level: 1, name: 'Credits, billing and limits', exact: true }) + ).toBeVisible() + const nextMarkdownUrl = new URL('/llms.mdx/platform/costs', origin).toString() + const nextLoaded = page.waitForResponse(nextMarkdownUrl) + await copy.click() + await (await nextLoaded).finished() + await expect(copy).toBeEnabled() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(copied) + await copy.click() + await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible() + await expect(page.locator('html')).toHaveAttribute('data-clipboard-activation', 'true') + expect(requests).toEqual([markdownUrl, nextMarkdownUrl]) + const nextCopied = await page.evaluate(() => navigator.clipboard.readText()) + expect(nextCopied).toContain('# Credits, billing and limits (/platform/costs)') + expect(nextCopied).toContain('## What a workflow run costs') + results.push({ + path: '/introduction', + viewport: 'legacy-clipboard', + status: 'passed', + durationMs: Math.round(performance.now() - started), + }) + } catch (error) { + failed = true + results.push({ + path: '/introduction', + viewport: 'legacy-clipboard', + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + }) + } finally { + await context.close() + } +} catch (error) { + failed = true + results.push({ path: 'browser', status: 'failed', error: getErrorMessage(error) }) +} finally { + await browser?.close() + await writeFile( + reportPath, + `${JSON.stringify({ checkedAt: new Date().toISOString(), origin: target.origin, results }, null, 2)}\n` + ) +} +logger.info('Verified documentation navigation', { pages: results.length, reportPath }) +if (failed) process.exitCode = 1 diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index a1fcf8495a3..c69f7fb4e50 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -12,6 +12,7 @@ const DOCS_DIR = 'content/docs' export const docs = defineDocs({ dir: DOCS_DIR, docs: { + async: true, schema: frontmatterSchema.extend({ integration: z.object({ type: z.string(), color: z.string() }).optional(), }), diff --git a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx index 9ba40936a0f..f6a6511cd27 100644 --- a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx +++ b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx @@ -1,5 +1,6 @@ import { type ClipboardEventHandler, + type KeyboardEventHandler, type ReactNode, type RefObject, useLayoutEffect, @@ -18,6 +19,7 @@ interface SearchInputBarProps { onChange: (value: string) => void onSubmit: () => void onPaste?: ClipboardEventHandler + onKeyDown?: KeyboardEventHandler placeholder?: string 'aria-label'?: string leadingControls?: ReactNode @@ -37,6 +39,7 @@ export function SearchInputBar({ onChange, onSubmit, onPaste, + onKeyDown, placeholder = 'Search your sources', 'aria-label': ariaLabel = 'Search your sources', selectionControl, @@ -108,6 +111,8 @@ export function SearchInputBar({ maxHeight={200} onChange={(event) => onChange(event.target.value)} onKeyDown={(event) => { + onKeyDown?.(event) + if (event.defaultPrevented) return if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault() onSubmit() diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 90ee806ad75..6fe552aecfe 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -375,64 +375,81 @@ async function paste(files: File[]) { return event } -it('keeps restored queued skills scoped when replacing a draft', async () => { - let restore: () => void = () => {} - function Harness() { - const [value, setValue] = useState('Original draft') - const [contexts, setContexts] = useState['restoredContexts']>() - restore = () => { - setValue('/review fix this') - setContexts([ - { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }, - ]) +it.each([false, true])( + 'keeps restored queued skills scoped and warns about lost context (%s)', + async (invalidContext) => { + let restore: () => void = () => {} + function Harness() { + const [value, setValue] = useState('Original draft') + const [contexts, setContexts] = + useState['restoredContexts']>() + restore = () => { + setValue('/review fix this @Browser') + setContexts([ + { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }, + ...(invalidContext + ? [ + { + kind: 'browser_tab' as const, + tabId: 'tab-a', + label: 'Browser', + selection: { text: 'Selected passage', title: 'x'.repeat(513) }, + }, + ] + : []), + ]) + } + const files = useFileAttachments({ + userId: 'user-a', + organizationId: 'organization-a', + requestMode: 'agent', + }) + return ( + {}} + onSubmit={mocks.submit} + /> + ) } - const files = useFileAttachments({ - userId: 'user-a', - organizationId: 'organization-a', - requestMode: 'agent', - }) - return ( - {}} - onSubmit={mocks.submit} - /> + await act(async () => + root.render( + + + + + + + ) ) - } - await act(async () => - root.render( - - - - - + await act(async () => restore()) + expect( + container.querySelector('[aria-label="Ask Sim"]')! + .value + ).toBe('\u2003review fix this @Browser') + if (invalidContext) expect(document.body.textContent).toContain('Reattach it before sending.') + await act(async () => + container.querySelector('button[aria-label="Send"]')!.click() ) - ) - await act(async () => restore()) - expect( - container.querySelector('[aria-label="Ask Sim"]')!.value - ).toBe('\u2003review fix this') - await act(async () => - container.querySelector('button[aria-label="Send"]')!.click() - ) - expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this', [ - { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }, - ]) -}) + expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this @Browser', [ + { kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }, + ]) + } +) it('shows global built-ins once with no workspace label and submits no invented workspace', async () => { mocks.workspaces = [] diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 25e1ef28eed..402b2bcd5f7 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -3,8 +3,8 @@ import { useEffect, useRef } from 'react' import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn' import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons' -import { escapeRegExp } from '@sim/utils/string' import { useQueries } from '@tanstack/react-query' +import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context' import { ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE, isAssistantImageType, @@ -25,9 +25,9 @@ import { } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options' import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut' -import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' +import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history' +import type { ChatMessage, ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' -import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import { getSkillsQueryOptions } from '@/hooks/queries/skills' import { useWorkspacesQuery } from '@/hooks/queries/workspace' import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' @@ -49,6 +49,8 @@ interface ComposerProps { restoredContexts?: ChatContext[] onSubmit: (text: string, contexts?: ChatContext[]) => void onSendQueuedHead?: () => void + onEditQueuedTail?: () => boolean + messages?: readonly ChatMessage[] onStop: () => void } @@ -69,6 +71,8 @@ export function Composer({ onChange, onSubmit, onSendQueuedHead, + onEditQueuedTail, + messages, restoredContexts, onStop, }: ComposerProps) { @@ -100,6 +104,12 @@ export function Composer({ initialContexts: restoredContexts, onPasteFiles: files.processFiles, }) + const history = usePromptHistory({ + editor, + attachedFiles: files.attachedFiles, + restoreAttachedFiles: files.restoreAttachedFiles, + getMessages: () => messages ?? [], + }) const { textareaRef } = editor const searchBlocked = editor.getActiveContexts().length > 0 || @@ -143,19 +153,13 @@ export function Composer({ }, [editor.value, editor.contexts, onChange]) useEffect(() => { if (!restoredContexts) return - // A queued skill may belong to a workspace whose picker has never opened here. - // Restore its existing chip from the saved context, without rediscovering it. - let restoredText = editorRef.current.getValue() - for (const context of restoredContexts) { - if (context.kind !== 'skill') continue - restoredText = restoredText.replace( - new RegExp(`(^|\\s)/${escapeRegExp(context.label)}(?=\\s|$)`, 'g'), - `$1${SKILL_CHIP_TRIGGER}${context.label}` - ) - } - editorRef.current.setValue(restoredText, { chipify: false }) - editorRef.current.setContexts(restoredContexts) - }, [restoredContexts]) + history.reset() + const { hasUnavailableContexts } = editorRef.current.restorePrompt( + editorRef.current.getPlainValue(), + restoredContexts + ) + if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE) + }, [restoredContexts, history.reset]) useChatInputFocus({ textareaRef }) const voice = useVoiceInput({ organizationId: organization.id, @@ -184,6 +188,7 @@ export function Composer({ voice.resetTranscript() const contexts = imagesOnly ? [] : editor.getActiveContexts() onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined) + history.reset() editor.clear() } @@ -207,12 +212,18 @@ export function Composer({ ) + const recallQueuedTail = () => { + if (attachedFilesRef.current.length > 0) return false + return onEditQueuedTail?.() ?? false + } const promptEditor = ( editor.setValue(text)} onSubmit={submit} onPaste={editor.handlePaste} + onKeyDown={(event) => + editor.handleKeyDown(event, { + onNavigateHistory: history.navigate, + onArrowUpOnEmpty: recallQueuedTail, + }) + } placeholder={placeholder} aria-label='Ask Sim' leadingControls={leadingControls} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index a4a7af5c530..c48be524d9f 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -299,6 +299,32 @@ function OrganizationHomeContent({ files.clearAttachedFiles() } + const editQueuedMessage = (id: string) => { + const queued = chat.editQueuedMessage(id) + if (queued) { + const queuedMode = queued.requestMode ?? requestMode + setSelectedMode(queuedMode) + setDraft(queued.content, queued.contexts) + setRestoredContexts(queued.contexts ?? []) + files.restoreAttachedFiles( + (queued.fileAttachments ?? []).map((file) => ({ + id: file.id, + key: file.key, + name: file.filename, + type: file.media_type, + size: file.size, + path: + file.path || + getMothershipAttachmentPreviewUrl(file) || + getMothershipAttachmentUrl(file), + previewUrl: getMothershipAttachmentPreviewUrl(file), + uploading: false, + })) + ) + } + return queued + } + const composer = requestMode !== 'assistant' && !canBuild ? (
@@ -317,6 +343,11 @@ function OrganizationHomeContent({ onModeChange={canSelectMode ? changeMode : undefined} value={draft} restoredContexts={restoredContexts} + messages={chat.messages} + onEditQueuedTail={() => { + const tail = chat.messageQueue.at(-1) + return tail ? Boolean(editQueuedMessage(tail.id)) : false + }} files={files} isInitialView={!hasChat} isSending={chat.isSending || chat.isReconnecting} @@ -357,31 +388,7 @@ function OrganizationHomeContent({ dispatchingHeadId={chat.dispatchingHeadId} onRemoveQueuedMessage={chat.removeFromQueue} onSendQueuedMessage={chat.sendNow} - onEditQueuedMessage={(id) => { - const queued = chat.editQueuedMessage(id) - if (queued) { - const queuedMode = queued.requestMode ?? requestMode - setSelectedMode(queuedMode) - setDraft(queued.content, queued.contexts) - setRestoredContexts(queued.contexts ?? []) - files.restoreAttachedFiles( - (queued.fileAttachments ?? []).map((file) => ({ - id: file.id, - key: file.key, - name: file.filename, - type: file.media_type, - size: file.size, - path: - file.path || - getMothershipAttachmentPreviewUrl(file) || - getMothershipAttachmentUrl(file), - previewUrl: getMothershipAttachmentPreviewUrl(file), - uploading: false, - })) - ) - } - return queued - }} + onEditQueuedMessage={editQueuedMessage} onCancelQueueEdit={chat.cancelQueueEdit} userId={session?.user?.id} chatId={chat.resolvedChatId} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx new file mode 100644 index 00000000000..57c104f3677 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.test.tsx @@ -0,0 +1,83 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActivityStatusProps } from '@/components/ui/activity-status' +import { usePacedActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' + +describe('usePacedActivity', () => { + let root: Root + let shown: ActivityStatusProps | undefined + + function Harness({ + label, + isActive, + callId, + placeholder, + }: { + label: string + isActive: boolean + callId: string + placeholder: boolean + }) { + shown = usePacedActivity({ label, isActive, icon: null }, callId, 'reset', false, placeholder) + return null + } + + function show(label: string, isActive: boolean, callId: string, placeholder = false) { + act(() => + root.render( + + ) + ) + return shown?.label + } + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.useFakeTimers() + root = createRoot(document.createElement('div')) + }) + + afterEach(() => { + act(() => root.unmount()) + vi.useRealTimers() + }) + + it("never shows a finished call's title between a run's calls", () => { + expect(show('Listing workflows', true, 'call-1')).toBe('Listing workflows') + act(() => vi.advanceTimersByTime(300)) + expect(show('Listed workflows', false, 'call-1')).toBe('Listing workflows') + expect(shown?.isActive).toBe(false) + act(() => vi.advanceTimersByTime(300)) + expect(show('Reading run logs', true, 'call-2')).toBe('Listing workflows') + act(() => vi.advanceTimersByTime(400)) + expect(shown?.label).toBe('Reading run logs') + }) + + it('settles on the finished title once the run stays idle', () => { + show('Listing workflows', true, 'call-1') + act(() => vi.advanceTimersByTime(300)) + show('Listed workflows', false, 'call-1') + act(() => vi.advanceTimersByTime(700)) + expect(shown?.label).toBe('Listed workflows') + }) + + it("replaces a placeholder with the call's title as soon as it streams", () => { + expect(show('Thinking', true, 'call-1', true)).toBe('Thinking') + act(() => vi.advanceTimersByTime(100)) + expect(show('Looking up the failed run', true, 'call-1')).toBe('Looking up the failed run') + }) + + it('holds a title that matched its placeholder text once the call names itself', () => { + show('Reading Gauge', true, 'call-1', true) + act(() => vi.advanceTimersByTime(100)) + expect(show('Reading Gauge', true, 'call-1')).toBe('Reading Gauge') + act(() => vi.advanceTimersByTime(300)) + expect(show('Read Gauge', false, 'call-1')).toBe('Reading Gauge') + act(() => vi.advanceTimersByTime(700)) + expect(shown?.label).toBe('Read Gauge') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx index 4da8091784b..1aad0aa40bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream.tsx @@ -15,55 +15,88 @@ interface ActivityStreamProps extends Omit { expandedLabel?: string attentionKey: string collapsible: boolean + /** The label only stands in until the header's call names itself (see `usePacedActivity`). */ + placeholder?: boolean } -/** Pace only the header; history and interactive tool state continue updating immediately. */ -export function ActivityStream({ - activity, - activityKey, - expandedLabel, - attentionKey, - expanded, - collapsible, - children, - onToggle, - isStreaming, - unbounded, -}: ActivityStreamProps) { - const isExpanded = collapsible && expanded +/** + * The header activity to show: a new label waits until the current one has shown for the pacing + * interval, and a burst of changes inside it settles on the latest. The label is paced across + * liveness too: between a run's calls the lane briefly loses its live indicator, and an unpaced + * label would flash the finished call's title before the next call's. Liveness itself is never + * held. A change of `resetKey` (attention, disclosure) and an expanded group show at once, and a + * placeholder (a call still awaiting its own title) never holds the header, so the title replaces + * it as soon as it streams. + */ +export function usePacedActivity( + activity: ActivityStatusProps, + activityKey: string | undefined, + resetKey: string, + immediate: boolean, + placeholder = false +): ActivityStatusProps { const key = `${activityKey}:${activity.label}` - const resetKey = `${activity.isActive}:${isExpanded}:${Boolean(activityKey)}:${attentionKey}` const [visible, setVisible] = useState(() => ({ activity, key, resetKey, + placeholder, shownAt: Date.now(), })) - /** Completion, attention, and disclosure changes bypass the cosmetic delay. */ if (visible.resetKey !== resetKey) { - setVisible({ activity, key, resetKey, shownAt: Date.now() }) + setVisible({ activity, key, resetKey, placeholder, shownAt: Date.now() }) + } else if (key === visible.key && placeholder !== visible.placeholder) { + // The same text now names the call itself (an activity title its description matches), so + // it holds the header from here. + setVisible({ ...visible, activity, placeholder, shownAt: Date.now() }) } useEffect(() => { - if (!activity.isActive || isExpanded || key === visible.key) return - const remaining = Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt)) - const flush = () => setVisible({ activity, key, resetKey, shownAt: Date.now() }) + if (immediate || key === visible.key) return + const remaining = visible.placeholder + ? 0 + : Math.max(0, ACTIVITY_UPDATE_INTERVAL_MS - (Date.now() - visible.shownAt)) + const flush = () => setVisible({ activity, key, resetKey, placeholder, shownAt: Date.now() }) if (remaining === 0) { flush() return } const timer = setTimeout(flush, remaining) return () => clearTimeout(timer) - }, [activity, key, resetKey, isExpanded, visible.key, visible.shownAt]) + }, [activity, key, resetKey, immediate, placeholder, visible]) + + return immediate || key === visible.key || visible.placeholder + ? activity + : { ...visible.activity, isActive: activity.isActive } +} - const displayed = - !activity.isActive || isExpanded || key === visible.key ? activity : visible.activity +/** Pace only the header; history and interactive tool state continue updating immediately. */ +export function ActivityStream({ + activity, + activityKey, + expandedLabel, + attentionKey, + expanded, + collapsible, + children, + onToggle, + isStreaming, + unbounded, + placeholder, +}: ActivityStreamProps) { + const isExpanded = collapsible && expanded + const displayed = usePacedActivity( + activity, + activityKey, + `${isExpanded}:${Boolean(activityKey)}:${attentionKey}`, + isExpanded, + placeholder + ) const header = ( ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index 5b584a432df..1b27d4a8260 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -5,7 +5,7 @@ import { ActivityTextColumn } from '@/components/ui/activity-status' import { ThinkingLoader } from '@/components/ui/thinking-loader' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' import type { ToolActivity } from '@/lib/mothership/generated/protocol' -import { readToolActivity } from '@/lib/mothership/tools/activity' +import { readCallActivity } from '@/lib/mothership/tools/activity' import { getToolDisplayTitle } from '@/lib/mothership/tools/display' import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' @@ -28,6 +28,8 @@ import { getActivityStatusTool, getCompletedActivityLabel, getInProgressActivityLabel, + isAwaitingTitle, + THINKING_LABEL, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' import { useToolCallTitle } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title' @@ -133,7 +135,7 @@ export function AgentGroupView({ const activityDescriptor = groupActivity ?? tools - .map((tool) => readToolActivity(tool.params, tool.streamingArgs)) + .map(readCallActivity) .reverse() .find((entry) => entry?.title || entry?.completedTitle) const browserAgentAvailable = isBrowserAgentAvailable() @@ -294,7 +296,7 @@ export function AgentGroupView({ tools, activityDescriptor?.title ) - : 'Thinking' + : THINKING_LABEL : tools.length > 0 ? getCompletedActivityLabel(tools, activityDescriptor) : agentLabel @@ -317,6 +319,7 @@ export function AgentGroupView({ { expect(container.textContent).not.toContain('No results') }) }) + +describe('in-progress activity header', () => { + const streamingCli: ToolCallData = { + id: 'cli-1', + toolName: 'sim_cli', + displayTitle: 'Running CLI command', + status: 'executing', + } + + function headerLabel(tools: ToolCallData[]) { + const statusTool = tools[tools.length - 1] + const headerTool = getActivityHeaderTool(tools, statusTool) + return getInProgressActivityLabel(headerTool.displayTitle ?? '', headerTool, tools, undefined) + } + + it('keeps the wait wording while a first CLI call has no title yet', () => { + expect(headerLabel([streamingCli])).toBe('Thinking') + }) + + it('uses the streamed description as soon as it arrives', () => { + const described = { + ...streamingCli, + activityDescription: 'Looking up the failed run', + displayTitle: 'Looking up the failed run', + } + expect(headerLabel([described])).toBe('Looking up the failed run') + }) + + it('holds the earlier call while a refined CLI call waits for its description', () => { + const earlier: ToolCallData = { + id: 'cli-0', + toolName: 'cli_workflows_list', + displayTitle: 'Listing workflows', + status: 'success', + activityDescription: 'Listing workflows', + params: { args: ['workflows', 'list'] }, + } + const refined = { ...streamingCli, toolName: 'cli_logs_get', displayTitle: 'Reading run logs' } + expect(getActivityHeaderTool([earlier, refined], refined)).toBe(earlier) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx index 39b5c6bb993..914423db3fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.tsx @@ -8,7 +8,7 @@ import { RunCode, SearchWorkspace, } from '@/lib/mothership/generated/tool-catalog-v1' -import { getToolActivitySummaryActions, readToolActivity } from '@/lib/mothership/tools/activity' +import { getToolActivitySummaryActions, readCallActivity } from '@/lib/mothership/tools/activity' import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/display' import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' @@ -95,14 +95,14 @@ function getToolActivityInterruptions(tools: ToolCallData[]): string[] { /** * An executing call whose streamed arguments do not name its action yet, so its * title is only its tool's placeholder: a search before its arguments arrive, - * a `sim_cli` call before its command parses ("Running CLI command"), - * a `run_code` call before its arguments + * a CLI call before its final frame (the verb parsed mid-stream would only flash + * ahead of the model's description), a `run_code` call before its arguments * resolve, while their parameters hold at most the activity, and an * integration gateway call before its description streams ("Calling integration"). * A call awaiting approval is never untitled, so its permission card keeps the * header, and neither is one the model already described. */ -function isAwaitingTitle(tool: ToolCallData): boolean { +export function isAwaitingTitle(tool: ToolCallData): boolean { if (tool.status !== ToolCallStatus.executing || tool.activityDescription) return false if (tool.toolName === CallIntegrationTool.id) { const description = @@ -111,6 +111,7 @@ function isAwaitingTitle(tool: ToolCallData): boolean { } return ( (tool.toolName === 'sim_cli' || + tool.toolName.startsWith('cli_') || tool.toolName === RunCode.id || tool.toolName === 'search_workspace') && Object.keys(tool.params ?? {}).every((key) => key === 'activity') @@ -144,11 +145,15 @@ export function getActivityHeaderTool( ) } +/** The wait wording: the turn between steps, and a first call whose own title is still streaming. */ +export const THINKING_LABEL = 'Thinking' + /** * The title of an activity in progress for its header call, with earlier * stops and skips kept visible. A header call still awaiting its title has no * earlier call to hold, so it reads as the activity's intent when the model - * gave one, else as its own in-progress title. + * gave one, else as the wait it continues: a tool's placeholder ("Running CLI + * command") would flash between the turn's "Thinking" and the model's description. */ export function getInProgressActivityLabel( activeLabel: string, @@ -156,7 +161,7 @@ export function getInProgressActivityLabel( tools: ToolCallData[], activityTitle: string | undefined ): string { - const label = (isAwaitingTitle(headerTool) && activityTitle) || activeLabel + const label = isAwaitingTitle(headerTool) ? activityTitle || THINKING_LABEL : activeLabel return getActiveToolActivityTitle(label, headerTool, tools) } @@ -223,7 +228,7 @@ export function ToolActivityGroup({ const groupedActivity = activity ?? tools - .map((tool) => readToolActivity(tool.params, tool.streamingArgs)) + .map(readCallActivity) .reverse() .find((entry) => entry?.title || entry?.completedTitle) /** Tense follows liveness: a live group, or one with a call still running, reads in progress. */ @@ -254,6 +259,7 @@ export function ToolActivityGroup({ icon: status.icon, }} activityKey={headerTool.id} + placeholder={working && isAwaitingTitle(headerTool)} attentionKey={attentionKey} expandedLabel={tools.length > 1 ? groupedActivity?.title : undefined} collapsible={tools.length > 1 || hasSearchDetails} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 5bf3b833b59..fbe9df3b9be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -16,7 +16,7 @@ import { isPlainRecord } from '@sim/utils/object' import type { ToolActivity } from '@/lib/mothership/generated/protocol' import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' import type { TaskBlockInfo } from '@/lib/mothership/request/types' -import { readToolActivity } from '@/lib/mothership/tools/activity' +import { readCallActivity } from '@/lib/mothership/tools/activity' import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils' import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state' @@ -37,6 +37,7 @@ import { ownsTurnWait, type TurnLiveIndicators, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' +import { THINKING_LABEL } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { WatchActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/watch-activity/watch-activity' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' @@ -247,6 +248,7 @@ function toToolData(tc: NonNullable): ToolCallData { displayTitle, activityDescription, status: tc.status, + activity: tc.activity, params: tc.params, result: tc.result, streamingArgs: tc.streamingArgs, @@ -566,10 +568,7 @@ function groupByActivity(segments: MessageSegment[], isStreaming: boolean): Mess const groups: AgentGroupSegment[] = [] let current: AgentGroupSegment | undefined for (const item of segment.items) { - const activity = - item.type === 'tool' - ? readToolActivity(item.data.params, item.data.streamingArgs) - : undefined + const activity = item.type === 'tool' ? readCallActivity(item.data) : undefined if (activity) labels.set(activity.id, { ...labels.get(activity.id), ...activity }) if (!current || (activity && activity.id !== current.activity?.id)) { current = { @@ -935,9 +934,9 @@ export function deriveThinkingLabel(blocks: ContentBlock[]): string { case 'tool_call': return last.toolCall && DISPATCH_TOOL_NAMES.has(last.toolCall.name) ? 'Dispatching' - : 'Thinking' + : THINKING_LABEL default: - return 'Thinking' + return THINKING_LABEL } } 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 aaab28c2f98..6e3abdb23e4 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 @@ -396,6 +396,9 @@ export function MothershipChat({ * starving the main thread on every streaming token. */ const messages = useDeferredValue(messagesProp) + const historyMessagesRef = useRef(messagesProp) + historyMessagesRef.current = messagesProp + const getHistoryMessages = useCallback(() => historyMessagesRef.current, []) const [lastRowAnimating, setLastRowAnimating] = useState(false) const containerRef = useRef(null) const scrollElementRef = useRef(null) @@ -779,16 +782,17 @@ export function MothershipChat({ const handleEditQueued = useCallback( (id: string) => { const msg = onEditQueuedMessage(id) - if (!msg) return - userInputRef.current?.loadQueuedMessage(msg) + if (!msg || !userInputRef.current) return false + userInputRef.current.loadQueuedMessage(msg) + return true }, [onEditQueuedMessage, userInputRef] ) const handleEditQueuedTail = useCallback(() => { const tail = messageQueueRef.current[messageQueueRef.current.length - 1] - if (!tail) return - handleEditQueued(tail.id) + if (!tail) return false + return handleEditQueued(tail.id) }, [handleEditQueued]) /** @@ -988,6 +992,7 @@ export function MothershipChat({ onSendQueuedHead={handleSendQueuedHead} onEditQueuedTail={handleEditQueuedTail} draftScopeKey={draftScopeKey} + getMessages={getHistoryMessages} /> ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index eb06e0b0973..d7d7ffda6e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -71,6 +71,7 @@ export function PromptEditor({ 'aria-label': ariaLabel, onSubmit, onArrowUpOnEmpty, + onNavigateHistory, }: PromptEditorProps) { const { textareaRef, value } = editor const scrollerRef = useRef(null) @@ -198,7 +199,9 @@ export function PromptEditor({ readOnly={readOnly} onChange={readOnly ? undefined : editor.handleInputChange} onKeyDown={ - readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty }) + readOnly + ? undefined + : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty, onNavigateHistory }) } onFocus={readOnly ? undefined : () => setHasFocused(true)} onPaste={readOnly ? undefined : editor.handlePaste} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index b7ae3876f54..d514dbfce52 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' +import { act, type ReactNode, useState } from 'react' import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock' import { createRoot, hydrateRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' @@ -11,11 +11,16 @@ vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) })) vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock) +import { toDisplayMessage } from '@/lib/mothership/chat/display-message' +import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard' import { type UsePromptEditorProps, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history' +import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' +import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { getIntegrationMatcher } from '@/blocks/integration-matcher' import type { ChatContext } from '@/stores/panel' @@ -526,3 +531,219 @@ it('re-chipifies a seeded draft once skills load', () => { act(() => root.unmount()) } }) + +describe('sent prompt history', () => { + it.each([ + ['workspace ownership', { kind: 'file', fileId: 'file-a', workspaceId: 42 }], + [ + 'conflicting file owner', + { + kind: 'file', + fileId: 'file-a', + workspaceId: 'ws-1', + owner: { entityType: 'workspace', entityId: 'ws-2' }, + }, + ], + ['saved table view', { kind: 'table', tableId: 'table-a', viewId: ['view-a'] }], + ['empty browser selection', { kind: 'browser_tab', tabId: 'tab-a', selection: { text: '' } }], + [ + 'terminal selection bounds', + { + kind: 'terminal_tab', + terminalId: 'terminal-a', + selection: { text: 'output', startLine: 10_000_001, endLine: 10_000_002 }, + }, + ], + ])('recalls text and valid chips when saved %s is malformed', (_name, context) => { + const valid = { kind: 'file', fileId: 'file-b', label: 'Valid' } + const message = toDisplayMessage( + normalizeMessage({ + id: 'stored', + role: 'user', + content: 'Compare @Invalid and @Valid', + contexts: [{ ...context, label: 'Invalid' }, valid], + }) + ) + const { result, unmount } = renderPromptEditor({ workspaceId: 'ws-1' }) + try { + act(() => { + expect( + result().restorePrompt(message.content, message.contexts).hasUnavailableContexts + ).toBe(true) + }) + expect(result().getPlainValue()).toBe('Compare @Invalid and @Valid') + expect(result().getActiveContexts()).toEqual([valid]) + } finally { + unmount() + } + }) + + it('recalls only user messages, walks both ways, and returns to the empty composer', () => { + const history = renderHistoryEditor([ + { id: 'first', role: 'user', content: 'first\nsecond line' }, + { id: 'answer', role: 'assistant', content: 'answer' }, + { id: 'latest', role: 'user', content: 'latest' }, + { id: 'task', role: 'user', origin: 'task', content: 'background notification' }, + ]) + try { + history.key('ArrowUp') + expect(history.textarea.value).toBe('latest') + history.key('ArrowUp') + expect(history.textarea.value).toBe('first\nsecond line') + history.key('ArrowUp') + expect(history.textarea.value).toBe('first\nsecond line') + history.end() + history.key('ArrowDown') + expect(history.textarea.value).toBe('latest') + history.key('ArrowDown') + expect(history.textarea.value).toBe('') + } finally { + history.unmount() + } + }) + + it('round trips saved skills, MCP, resource context and attachments without a loaded picker', () => { + const contexts: ChatContext[] = [ + { kind: 'skill', skillId: 'skill-a', workspaceId: 'workspace-a', label: 'review' }, + { kind: 'mcp', serverId: 'server-a', label: 'tools' }, + { kind: 'file', fileId: 'file-a', label: 'Plan.md' }, + ] + const attachment = { + id: 'attachment-a', + key: 'uploads/file-a', + filename: 'image.png', + media_type: 'image/png', + size: 30, + } + const content = '(/review), [/tools]! (@Plan.md) and /reviewer and path/review' + const history = renderHistoryEditor([ + { id: 'rich', role: 'user', content, contexts, fileAttachments: [attachment] }, + ]) + try { + history.key('ArrowUp') + expect(history.editor().getPlainValue()).toBe(content) + expect(history.editor().getActiveContexts()).toEqual(contexts) + expect(history.files()).toMatchObject([ + { id: attachment.id, key: attachment.key, uploading: false }, + ]) + expect(history.textarea.value).toBe( + '(\u2003review), [\u2003tools]! (@Plan.md) and /reviewer and path/review' + ) + history.end() + history.key('ArrowDown') + expect(history.editor().getActiveContexts()).toEqual([]) + expect(history.files()).toEqual([]) + } finally { + history.unmount() + } + }) + + it('does not replace drafts, selections, composing text, or modified arrow navigation', () => { + const history = renderHistoryEditor([{ id: 'first', role: 'user', content: 'sent' }]) + try { + for (const modifiers of [ + { shiftKey: true }, + { altKey: true }, + { ctrlKey: true }, + { metaKey: true }, + { isComposing: true }, + ]) { + history.key('ArrowUp', modifiers) + expect(history.textarea.value).toBe('') + } + history.setText('my draft') + history.textarea.setSelectionRange(0, 0) + history.key('ArrowUp') + expect(history.textarea.value).toBe('my draft') + history.setText('') + history.key('ArrowUp') + history.textarea.setSelectionRange(0, 2) + expect(history.key('ArrowUp')).toBe(false) + expect(history.textarea.value).toBe('sent') + history.setText('edited recalled message') + history.end() + history.key('ArrowDown') + expect(history.textarea.value).toBe('edited recalled message') + } finally { + history.unmount() + } + }) + + it('preserves multiline movement and follows message identity when older history arrives', () => { + const history = renderHistoryEditor([{ id: 'latest', role: 'user', content: 'one\ntwo' }]) + try { + history.key('ArrowUp') + history.textarea.setSelectionRange(5, 5) + expect(history.key('ArrowUp')).toBe(false) + expect(history.textarea.value).toBe('one\ntwo') + history.prepend({ id: 'older', role: 'user', content: 'older' }) + history.textarea.setSelectionRange(0, 0) + history.key('ArrowUp') + expect(history.textarea.value).toBe('older') + } finally { + history.unmount() + } + }) +}) + +function renderHistoryEditor(initialMessages: ChatMessage[]) { + let messages = initialMessages + let latest: ReturnType | undefined + let currentFiles: AttachedFile[] = [] + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + function HistoryProbe() { + const editor = usePromptEditor({ workspaceId: 'ws-1' }) + const [attachedFiles, restoreAttachedFiles] = useState([]) + const history = usePromptHistory({ + editor, + attachedFiles, + restoreAttachedFiles, + getMessages: () => messages, + }) + latest = editor + currentFiles = attachedFiles + return ( +