{isLoading && !hasMessages ? (
) : (
@@ -714,8 +736,15 @@ export function MothershipChat({
key={virtualItem.key}
data-index={index}
ref={virtualizer.measureElement}
- className='absolute top-0 left-0 w-full'
- style={{ transform: `translateY(${virtualItem.start}px)` }}
+ /* Positioned with a real `top`, NOT `top-0` + translateY:
+ text selection maps a drag's start point to a text
+ position via the rows' LAYOUT boxes, and with every row
+ laid out at y=0 a drag starting in the gutter anchors in
+ the wrong row — selections ran upward from a downward
+ drag. Transforms move paint and hit-testing but not the
+ layout box that mapping falls back to. */
+ className='absolute left-0 w-full'
+ style={{ top: virtualItem.start }}
>
{msg.role === 'user' ? (
interactionPairing.hiddenUserByIndex[index] ? null : (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
index 63c974fcbcf..23b0947471c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts
@@ -30,6 +30,7 @@ import {
createBrowserPanelGeometryOcclusionLease,
hasNativeSurfaceOcclusion,
NATIVE_SURFACE_OCCLUSION_SELECTOR,
+ snapshotMatchesHost,
useBrowserPanelOcclusion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion'
@@ -582,3 +583,44 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => {
hook.unmount()
})
})
+
+describe('snapshotMatchesHost', () => {
+ const rect = (x: number, y: number, width: number, height: number) =>
+ ({ x, y, width, height }) as DOMRect
+
+ it('accepts a capture that still describes the host rect', () => {
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10, 20, 800, 600)
+ )
+ ).toBe(true)
+ })
+
+ it('tolerates sub-pixel drift from rounding', () => {
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10.4, 19.6, 800.5, 599.5)
+ )
+ ).toBe(true)
+ })
+
+ it('rejects a capture taken before a scroll lock reflowed the panel', () => {
+ // Modal scroll lock removes the scrollbar: the host widens by 15px, so the
+ // pre-lock capture would paint misaligned — the flash this guards.
+ expect(
+ snapshotMatchesHost(
+ { viewportBounds: { x: 10, y: 20, width: 800, height: 600 } },
+ rect(10, 20, 815, 600)
+ )
+ ).toBe(false)
+ })
+
+ it('accepts captures with no viewport bounds (host-tracking fallback style)', () => {
+ expect(snapshotMatchesHost({ viewportBounds: undefined }, rect(0, 0, 100, 100))).toBe(true)
+ expect(
+ snapshotMatchesHost({ viewportBounds: { x: 0, y: 0, width: 10, height: 10 } }, null)
+ ).toBe(true)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
index b4b76eb9367..3f7acedff76 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts
@@ -184,10 +184,34 @@ async function decodeSnapshot(dataUrl: string): Promise
{
* changing layers never reveals or recaptures the native view, and the view is
* revealed only after the final reason disappears.
*/
+
+/** Largest tolerated drift, in CSS px, between a capture and the live host rect. */
+const SNAPSHOT_GEOMETRY_TOLERANCE_PX = 1
+
+/**
+ * Whether a captured frame still describes the rectangle the panel occupies.
+ * A capture with no viewport bounds is positioned by the fallback
+ * `absolute inset-0` style, which tracks the host by construction.
+ */
+export function snapshotMatchesHost(
+ frame: Pick,
+ hostRect: DOMRect | null
+): boolean {
+ const bounds = frame.viewportBounds
+ if (!bounds || !hostRect) return true
+ return (
+ Math.abs(bounds.x - hostRect.x) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.y - hostRect.y) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.width - hostRect.width) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX &&
+ Math.abs(bounds.height - hostRect.height) <= SNAPSHOT_GEOMETRY_TOLERANCE_PX
+ )
+}
+
export function useBrowserPanelOcclusion(
scopeId: string,
activeTabId: string | null,
- panelVisible = true
+ panelVisible = true,
+ getHostRect?: () => DOMRect | null
): BrowserPanelOcclusion {
const [snapshotRender, setSnapshotRender] = useState(null)
const [activeOverlay, setActiveOverlay] = useState(null)
@@ -204,8 +228,10 @@ export function useBrowserPanelOcclusion(
const paintFramesRef = useRef([])
const reconcileChainRef = useRef>(Promise.resolve(true))
const mountedRef = useRef(true)
+ const getHostRectRef = useRef(getHostRect)
activeTabIdRef.current = activeTabId
panelVisibleRef.current = panelVisible
+ getHostRectRef.current = getHostRect
const updateSnapshotRender = useCallback((render: SnapshotRender | null) => {
snapshotRenderRef.current = render
@@ -303,7 +329,7 @@ export function useBrowserPanelOcclusion(
// Modal scroll locking can alter panel geometry between capture and the
// final native hide. One fresh capture retries that now-settled layout.
- const maxAttempts = desired === 'modal' ? 2 : 1
+ const maxAttempts = desired === 'modal' ? 3 : 1
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const frame = await captureBrowserPanelSnapshot(scopeId).catch(() => null)
if (!mountedRef.current || version !== transitionVersionRef.current) return false
@@ -318,6 +344,13 @@ export function useBrowserPanelOcclusion(
desired = desiredLayer()
if (!desired || !decoded) continue
+ // Painting a capture whose geometry no longer matches the host is the
+ // flash: a modal's scroll lock changes the window's content width
+ // between capture and paint, so the replacement lands offset from the
+ // page it is standing in for. Skip that frame and re-capture at the
+ // settled layout instead of showing a misaligned one.
+ if (!snapshotMatchesHost(frame, getHostRectRef.current?.() ?? null)) continue
+
const paintId = ++paintIdRef.current
const painted = new Promise((resolve) => {
pendingPaintRef.current = { paintId, resolve }
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
index 0edd0e9f3f0..957dab4d10d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts
@@ -190,6 +190,10 @@ describe('initialUrlSuggestionIndex', () => {
expect(initialUrlSuggestionIndex('https://sim.ai', 3)).toBeNull()
})
+ it('selects the exact search row after typing on an existing page', () => {
+ expect(initialUrlSuggestionIndex('https://sim.ai', 3, 'what is the best')).toBe(0)
+ })
+
it('selects nothing when there are no suggestions', () => {
expect(initialUrlSuggestionIndex('', 0)).toBeNull()
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
index 3d241ca738b..b4e7d90c0f8 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx
@@ -39,6 +39,7 @@ import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-short
import {
fillBrowserCredential,
loadBrowserFillOptions,
+ loadBrowserSearchSuggestions,
loadBrowserSuggestionSources,
onBrowserAddToChat,
onBrowserAppearanceThemeChanged,
@@ -84,12 +85,16 @@ import {
import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip'
import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice'
import {
+ buildOmniboxSuggestions,
+ googleSearchUrl,
+ isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
- rankSuggestions,
+ type OmniboxSuggestion,
type UrlSuggestion,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions'
import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items'
+import { useDebounce } from '@/hooks/use-debounce'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
@@ -97,6 +102,7 @@ import type { ChatContext } from '@/stores/panel'
/** Ties the omnibox to its listbox for assistive tech. */
const SUGGESTIONS_LIST_ID = 'browser-url-suggestions'
+const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160
const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
@@ -156,14 +162,10 @@ export function browserSelectionContext({
export function resolveUrlBarInput(raw: string): string {
const input = raw.trim()
if (/^https?:\/\//i.test(input)) return input
- const hostLike =
- /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
- if (!input.includes(' ') && hostLike.test(input)) {
- const isLocal =
- /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
- return `${isLocal ? 'http' : 'https'}://${input}`
- }
- return `https://www.google.com/search?q=${encodeURIComponent(input)}`
+ if (isSearchQueryInput(input)) return googleSearchUrl(input)
+ const isLocal =
+ /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input)
+ return `${isLocal ? 'http' : 'https'}://${input}`
}
/**
@@ -302,13 +304,14 @@ export function shouldOpenUrlSuggestions(
return activeOverlay === 'suggestions' && suggestionCount > 0
}
-/** New tabs submit the best suggestion; existing pages submit their current URL. */
+/** Typed searches and new tabs select the first row; an untouched page URL remains literal. */
export function initialUrlSuggestionIndex(
pageUrl: string | undefined,
- suggestionCount: number
+ suggestionCount: number,
+ query = ''
): number | null {
if (suggestionCount === 0) return null
- return !pageUrl || pageUrl === 'about:blank' ? 0 : null
+ return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null
}
/** A new-tab request is complete only after the authoritative strip grows and activates a new id. */
@@ -369,6 +372,9 @@ export function BrowserSession({
const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false)
const panelRef = useRef(null)
const hostRef = useRef(null)
+ // Lets the occlusion handshake reject a capture taken before a modal's
+ // scroll lock reflowed the panel — painting that stale rect is the flash.
+ const getHostRect = useCallback(() => hostRef.current?.getBoundingClientRect() ?? null, [])
const urlInputRef = useRef(null)
const findInputRef = useRef(null)
const fillButtonRef = useRef(null)
@@ -429,6 +435,15 @@ export function BrowserSession({
const [suggestionsVisible, setSuggestionsVisible] = useState(false)
/** Empty on initial focus; follows the typed text once the user edits it. */
const [suggestionQuery, setSuggestionQuery] = useState(null)
+ /** Live completions tagged with the query that produced them, so late replies cannot leak in. */
+ const [searchCompletions, setSearchCompletions] = useState<{
+ query: string
+ values: string[]
+ }>({ query: '', values: [] })
+ const debouncedSuggestionQuery = useDebounce(
+ suggestionQuery ?? '',
+ SEARCH_SUGGESTIONS_DEBOUNCE_MS
+ )
/** Whether the find bar is docked above the page. */
const [findOpen, setFindOpen] = useState(false)
const {
@@ -438,7 +453,7 @@ export function BrowserSession({
requestOverlay,
closeOverlay,
onSnapshotError,
- } = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible)
+ } = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible, getHostRect)
// The resource picker lives above this component in the panel tab bar. Give
// that one external browser overlay access to the same capture/hide handshake
@@ -502,6 +517,23 @@ export function BrowserSession({
}
}, [panelVisible])
+ /** Debounced live completions never block the immediate local/search row. */
+ useEffect(() => {
+ const query = debouncedSuggestionQuery.trim()
+ if (!suggestionsVisible || !isSearchQueryInput(query)) {
+ setSearchCompletions({ query: '', values: [] })
+ return
+ }
+
+ let active = true
+ void loadBrowserSearchSuggestions(query).then((values) => {
+ if (active) setSearchCompletions({ query, values })
+ })
+ return () => {
+ active = false
+ }
+ }, [debouncedSuggestionQuery, suggestionsVisible])
+
useEffect(() => {
if (appearanceTheme) {
const next = resolveDesktopAppearanceTheme(appearanceTheme, theme)
@@ -579,6 +611,24 @@ export function BrowserSession({
useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId])
+ // Follow the agent's tab. The panel already marks the automated tab in the
+ // strip; this makes it the VISIBLE one, so watching the agent never means
+ // hunting for which tab it moved to. Keyed on the automation target
+ // CHANGING, not on it merely being set — the user can still browse a
+ // different tab mid-run and is only pulled along when the agent itself
+ // moves to another tab.
+ const followedAutomationTabRef = useRef(null)
+ useEffect(() => {
+ if (!automationActive || !automationTabId) {
+ if (!automationActive) followedAutomationTabRef.current = null
+ return
+ }
+ if (followedAutomationTabRef.current === automationTabId) return
+ followedAutomationTabRef.current = automationTabId
+ if (automationTabId === activeTabId) return
+ sendBrowserPanelAction('switch-tab', { tabId: automationTabId }, scopeId)
+ }, [activeTabId, automationActive, automationTabId, scopeId])
+
// Sim owns keyboard events while its renderer has focus. Claim Cmd+L here
// before the workspace's global "Go to Logs" command can navigate away.
useEffect(() => {
@@ -748,6 +798,17 @@ export function BrowserSession({
// then report bounds: the WebContentsView is attached hidden from its
// very first compositor frame. This also reasserts the lease after a
// minimized/throttled window temporarily loses its bounds.
+ //
+ // The reassert must be REAL, not deduped. Main's bounds lease expires
+ // after 2.5s of missed reports (renderer jank, a skipped commit) and
+ // resets panelOccluded — while this side still remembers `applied:
+ // true`. Without dropping that belief, setDesired(true) is a no-op,
+ // the next bounds commit lays out an unoccluded native view, and the
+ // browser punches above the still-open modal with nothing left to
+ // ever re-hide it. Forgetting `applied` costs one idempotent hide IPC
+ // per heartbeat while a modal is up, and makes any main-side lease
+ // loss self-heal within a second.
+ if (nativeSurfaceOcclusionPresent) geometryOcclusionLease.assumeRevealed()
void geometryOcclusionLease.setDesired(nativeSurfaceOcclusionPresent).then((settled) => {
if (disposed) return
const latestOcclusionPresent = atomicPanelOcclusion && hasNativeSurfaceOcclusion()
@@ -833,17 +894,18 @@ export function BrowserSession({
* Programmatic focus on a new tab keeps the omnibox ready for typing without
* opening this list. A pointer interaction or typed edit opts into suggestions.
*/
- const suggestions = useMemo(
- () =>
- suggestionsVisible && suggestionQuery !== null
- ? rankSuggestions(suggestionCorpus, suggestionQuery)
- : [],
- [suggestionCorpus, suggestionQuery, suggestionsVisible]
- )
+ const suggestions = useMemo((): OmniboxSuggestion[] => {
+ if (!suggestionsVisible || suggestionQuery === null) return []
+ const query = suggestionQuery.trim()
+ const live = searchCompletions.query === query ? searchCompletions.values : []
+ return buildOmniboxSuggestions(suggestionCorpus, suggestionQuery, live)
+ }, [searchCompletions, suggestionCorpus, suggestionQuery, suggestionsVisible])
useEffect(() => {
- setActiveSuggestion(initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length))
- }, [suggestionOriginUrl, suggestions])
+ setActiveSuggestion(
+ initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length, suggestionQuery ?? '')
+ )
+ }, [suggestionOriginUrl, suggestionQuery, suggestions])
// The suggestion list is renderer UI that extends over the native page.
// Keep the page's exact captured frame underneath it while it is open so
@@ -1075,6 +1137,7 @@ export function BrowserSession({
placeholder='Search Google or enter a URL'
autoComplete='off'
role='combobox'
+ aria-autocomplete='list'
aria-expanded={suggestionsOpen}
aria-controls={SUGGESTIONS_LIST_ID}
aria-activedescendant={
@@ -1092,6 +1155,7 @@ export function BrowserSession({
onChange={(event) => {
setSuggestionsVisible(true)
setSuggestionQuery(event.target.value)
+ setSearchCompletions({ query: '', values: [] })
setUrlDraft(event.target.value)
// The old highlight pointed at a row that may no longer be
// in the list, let alone in the same position.
@@ -1150,7 +1214,11 @@ export function BrowserSession({
>
{suggestions.map((suggestion, index) => (
navigateTo(suggestion.url)}
>
-
- {suggestion.name ? (
+ {suggestion.kind === 'search' ? (
+ <>
+
+
{suggestion.query}
+ >
+ ) : (
+
+ )}
+ {suggestion.kind === 'site' && suggestion.name ? (
{suggestion.name}
— {suggestion.hostname}
- ) : (
+ ) : suggestion.kind === 'site' ? (
{suggestion.hostname}
- )}
+ ) : null}
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
index f4407ab3f6a..978d67badf1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts
@@ -2,6 +2,9 @@ import type { BrowserKnownSession, BrowserSessionEvidence } from '@sim/browser-p
import type { BrowserCredentialMetadata, BrowserSiteInfo } from '@sim/desktop-bridge'
import { describe, expect, it } from 'vitest'
import {
+ buildOmniboxSuggestions,
+ googleSearchUrl,
+ isSearchQueryInput,
mergeSuggestionSources,
moveActiveIndex,
rankSuggestions,
@@ -421,6 +424,65 @@ describe('rankSuggestions', () => {
})
})
+describe('buildOmniboxSuggestions', () => {
+ it('leads with the exact search, keeps matching sites, then adds live completions', () => {
+ const results = buildOmniboxSuggestions(
+ [suggestion('mail.google.com', 100, 'Gmail')],
+ 'gmail',
+ ['gmail login', 'gmail account']
+ )
+
+ expect(results.map((result) => [result.kind, result.url])).toEqual([
+ ['search', googleSearchUrl('gmail')],
+ ['site', 'https://mail.google.com'],
+ ['search', googleSearchUrl('gmail login')],
+ ['search', googleSearchUrl('gmail account')],
+ ])
+ })
+
+ it('does not send URL-looking input through search completions', () => {
+ const results = buildOmniboxSuggestions([suggestion('github.com', 100)], 'github.com', [
+ 'github.com login',
+ ])
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
+ })
+
+ it('deduplicates completions and caps the combined dropdown', () => {
+ const results = buildOmniboxSuggestions(
+ [],
+ 'sim ai',
+ ['sim ai', 'SIM AI', 'sim ai workflow', 'sim ai agents'],
+ 2
+ )
+
+ expect(results.map((result) => result.kind === 'search' && result.query)).toEqual([
+ 'sim ai',
+ 'sim ai workflow',
+ ])
+ })
+
+ it('keeps an empty omnibox local-only', () => {
+ const results = buildOmniboxSuggestions([suggestion('github.com', 100)], '', [
+ 'ignored remote completion',
+ ])
+
+ expect(results).toHaveLength(1)
+ expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' })
+ })
+})
+
+describe('isSearchQueryInput', () => {
+ it('distinguishes searches from navigable addresses', () => {
+ expect(isSearchQueryInput('what is the best browser')).toBe(true)
+ expect(isSearchQueryInput('electron')).toBe(true)
+ expect(isSearchQueryInput('sim.ai/docs')).toBe(false)
+ expect(isSearchQueryInput('https://sim.ai')).toBe(false)
+ expect(isSearchQueryInput('localhost:3000')).toBe(false)
+ })
+})
+
describe('moveActiveIndex', () => {
it('highlights nothing until the user arrows in, so Enter still means "go to what I typed"', () => {
expect(moveActiveIndex(null, 1, 3)).toBe(0)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
index c5ae7399c7f..4ab226a38ff 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts
@@ -47,6 +47,25 @@ export interface UrlSuggestion {
visits?: number
}
+export type OmniboxSuggestion =
+ | ({ kind: 'site' } & UrlSuggestion)
+ | { kind: 'search'; query: string; url: string }
+
+const HOST_LIKE_INPUT =
+ /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i
+
+/** Whether an omnibox value should search rather than navigate directly. */
+export function isSearchQueryInput(raw: string): boolean {
+ const input = raw.trim()
+ if (!input || /^https?:\/\//i.test(input)) return false
+ return input.includes(' ') || !HOST_LIKE_INPUT.test(input)
+}
+
+/** The canonical Google results URL used by search rows and bare submission. */
+export function googleSearchUrl(query: string): string {
+ return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}`
+}
+
function timestamp(value: string | undefined): number {
if (!value) return 0
const parsed = Date.parse(value)
@@ -189,6 +208,42 @@ export function rankSuggestions(
return scored.slice(0, limit).map((entry) => entry.suggestion)
}
+/**
+ * Combines immediate navigation/search actions with the user's known sites and
+ * live completions. The exact typed search leads, known sites retain priority,
+ * and remote completions fill whatever room remains.
+ */
+export function buildOmniboxSuggestions(
+ siteCorpus: readonly UrlSuggestion[],
+ rawQuery: string,
+ searchCompletions: readonly string[] = [],
+ limit: number = MAX_URL_SUGGESTIONS
+): OmniboxSuggestion[] {
+ if (limit <= 0) return []
+ const query = rawQuery.trim()
+ const sites = rankSuggestions(siteCorpus, query, limit)
+ if (!query || !isSearchQueryInput(query)) {
+ return sites.map((site) => ({ ...site, kind: 'site' }))
+ }
+
+ const results: OmniboxSuggestion[] = [{ kind: 'search', query, url: googleSearchUrl(query) }]
+ for (const site of sites) {
+ if (results.length === limit) return results
+ results.push({ ...site, kind: 'site' })
+ }
+
+ const seen = new Set([query.toLocaleLowerCase()])
+ for (const candidate of searchCompletions) {
+ const completion = candidate.trim()
+ const key = completion.toLocaleLowerCase()
+ if (!completion || seen.has(key)) continue
+ seen.add(key)
+ results.push({ kind: 'search', query: completion, url: googleSearchUrl(completion) })
+ if (results.length === limit) break
+ }
+ return results
+}
+
/**
* How well the browser knows a host, then how much it is used, then how
* recently, then alphabetically so the same corpus always comes back in the
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 4378ba9a3cf..48f3769b17b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -256,6 +256,7 @@ export const ResourceContent = memo(function ResourceContent({
tableId={resource.id}
embedded
viewsEnabled={tableViewsEnabled}
+ initialViewId={resource.viewId}
/>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index d907369426b..b4333837c48 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({
>
{config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
{displayName}
- {hasActivity && !isActive && (
+ {hasActivity && !isActive && !isHovered && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index d82d73db634..621e541faef 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -4,6 +4,7 @@ import { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
+import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
isCsvStreamOnly,
@@ -164,7 +165,10 @@ export const MothershipView = memo(
// the record before deciding so the toggle doesn't flash on for a large CSV — but don't gate
// other rich types (html, svg, …) on the file list loading.
!(isActiveCsv && filesLoading) &&
- !(activeFile && isCsvStreamOnly(activeFile))
+ !(activeFile && isCsvStreamOnly(activeFile)) &&
+ // A Sim page is locked to its rendered view (the pdf model — the raw
+ // source is not a mode this surface offers), so no toggle either.
+ activeFile?.type !== SIM_PAGE_CONTENT_TYPE
return (
new Set(current).add(resourceId))
return
}
@@ -228,7 +234,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
next.delete(resourceId)
return next
})
- if (activeResourceId !== resourceId) setActiveResourceUrl(resourceId)
+ if (activeResourceId !== resourceId) {
+ activeResourceParamRef.current = resourceId
+ setActiveResourceUrl(resourceId)
+ }
}
const {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
index 995df519868..8c1fa13edd3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts
@@ -1,6 +1,8 @@
+export type { ResourceEventOptions } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
+ shouldActivateResourceEvent,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
index 296f48bc559..3635ac65d5c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts
@@ -4,7 +4,7 @@ import {
MothershipStreamV1ToolPhase,
MothershipStreamV1ToolStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
-import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
+import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
extractResourcesFromToolResult,
@@ -79,7 +79,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id)
}
- if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) {
+ if ((name === ApplyFileEdit.id || name === PrepareFileEdit.id) && isSuccess) {
const out = output as Record
| undefined
const editData =
out && typeof out.data === 'object' && out.data !== null
@@ -102,17 +102,20 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
deps.onToolResultRef.current?.(name, isSuccess, output)
const workspaceFileOperation =
- name === WorkspaceFile.id && typeof params?.operation === 'string'
+ name === PrepareFileEdit.id && typeof params?.operation === 'string'
? params.operation
: undefined
const shouldKeepWorkspacePreviewOpen =
- name === WorkspaceFile.id &&
+ name === PrepareFileEdit.id &&
(workspaceFileOperation === 'append' ||
workspaceFileOperation === 'update' ||
workspaceFileOperation === 'patch')
- if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) {
- if (name === WorkspaceFile.id) {
+ if (
+ (name === PrepareFileEdit.id || name === ApplyFileEdit.id) &&
+ !shouldKeepWorkspacePreviewOpen
+ ) {
+ if (name === PrepareFileEdit.id) {
deps.removePreviewSessionImmediate(node.id)
}
const fileResource = extractedResources.find((r) => r.type === 'file')
@@ -128,7 +131,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
/**
* Side effects for tool events. State (the tool node, its status, args, and the
- * edit_content row merge) is owned by `reduceEvent`; this handler routes preview
+ * apply_file_edit row merge) is owned by `reduceEvent`; this handler routes preview
* phases, fires client workflow tools, and runs result side effects, then
* flushes the model-derived snapshot.
*/
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
index 90e8d0f9cac..afeb703e08d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts
@@ -2,35 +2,37 @@ import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import {
CallIntegrationTool,
- CrawlWebsite,
- CreateFile,
+ CreateEmptyFile,
CreateWorkflow,
- DeployApi,
- DeployChat,
- DeployMcp,
+ DeployAsApi,
+ DeployAsChat,
+ DeployAsMcp,
EditWorkflow,
- FunctionExecute,
Glob,
Grep,
ManageCredential,
ManageCustomTool,
- ManageMcpTool,
+ ManageMcpConnection,
ManageSkill,
+ PrepareFileEdit,
+ PrepareFileEditOperation,
QueryLogs,
Redeploy,
Rm,
RunFromBlock,
+ RunFunction,
RunWorkflow,
RunWorkflowUntilBlock,
- ScrapePage,
- SearchOnline,
- WorkspaceFile,
- WorkspaceFileOperation,
+ WebCrawl,
+ WebScrape,
+ WebSearch,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display'
+import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
+import { tableKeys } from '@/hooks/queries/utils/table-keys'
import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,9 +42,9 @@ const logger = createLogger('StreamHelpers')
export const FILE_SUBAGENT_ID = 'file'
export const DEPLOY_TOOL_NAMES: Set = new Set([
- DeployApi.id,
- DeployChat.id,
- DeployMcp.id,
+ DeployAsApi.id,
+ DeployAsChat.id,
+ DeployAsMcp.id,
Redeploy.id,
])
@@ -123,6 +125,33 @@ function resolveTargetWorkflowName(args: Record | undefined): s
return resolveWorkflowNameForDisplay(args?.workflowId ?? registry.hydration.workflowId)
}
+/**
+ * Table name for a nested `args.tableId`. Tables reach the client through
+ * React Query rather than a Zustand store, so the cached workspace list is
+ * the synchronous source a title can read; an uncached id simply stays
+ * unnamed rather than blocking the row.
+ */
+function resolveTableNameForDisplay(tableId: unknown): string | undefined {
+ const id = stringParam(tableId)
+ if (!id) return undefined
+ const cache = getQueryClient().getQueryCache()
+ for (const query of cache.findAll({ queryKey: tableKeys.lists() })) {
+ const data = query.state.data
+ const tables = Array.isArray(data)
+ ? data
+ : isRecordLike(data) && Array.isArray((data as { tables?: unknown }).tables)
+ ? ((data as { tables: unknown[] }).tables as unknown[])
+ : []
+ for (const table of tables) {
+ if (!isRecordLike(table)) continue
+ if (stringParam(table.id) !== id) continue
+ const name = stringParam(table.name)
+ if (name) return name
+ }
+ }
+ return undefined
+}
+
function resolveBlockNameForDisplay(blockId: unknown): string | undefined {
const id = stringParam(blockId)
if (!id) return undefined
@@ -139,13 +168,13 @@ function resolveWorkspaceFileDisplayTitle(
let verb = 'Writing'
switch (operation) {
- case WorkspaceFileOperation.append:
+ case PrepareFileEditOperation.append:
verb = 'Adding'
break
- case WorkspaceFileOperation.patch:
+ case PrepareFileEditOperation.patch:
verb = 'Editing'
break
- case WorkspaceFileOperation.update:
+ case PrepareFileEditOperation.update:
verb = 'Writing'
break
}
@@ -186,6 +215,40 @@ export function resolveIntegrationToolDisplayTitle(tool: {
return tool.integrationDescription
}
+/**
+ * Tools whose subject is one workflow. They accept a `workflowId` (or imply
+ * the current workflow), so their titles can only name the workflow once the
+ * client resolves the id against the workflow registry.
+ */
+const TABLE_SCOPED_TOOL_IDS = new Set([
+ 'table_automations',
+ 'table_columns',
+ 'table_enrichments',
+ 'table_manage',
+ 'table_rows',
+ 'table_views',
+])
+
+const WORKFLOW_SCOPED_TOOL_IDS = new Set([
+ 'deploy_as_api',
+ 'diff_workflows',
+ 'list_deployment_versions',
+ 'publish_custom_block',
+ 'deploy_as_chat',
+ 'deploy_as_mcp',
+ 'get_block_outputs',
+ 'get_block_upstream_references',
+ 'get_deployed_workflow_state',
+ 'get_deployment_status',
+ 'get_workflow_data',
+ 'get_workflow_run_options',
+ 'promote_to_live',
+ 'redeploy',
+ 'run_block',
+ 'set_block_enabled',
+ 'set_global_workflow_variables',
+])
+
export function resolveToolDisplayTitle(name: string, args?: Record): string {
// Cases that enrich the title with live workspace/block names from the client
// stores. Everything else is resolved by the shared name+args resolver, which
@@ -224,6 +287,39 @@ export function resolveToolDisplayTitle(name: string, args?: Record) : undefined
+ const tableName =
+ stringParam(args?.tableName) ?? resolveTableNameForDisplay(nested?.tableId ?? args?.tableId)
+ if (nested || tableName) {
+ return getToolDisplayTitle(name, {
+ ...args,
+ ...(nested ?? {}),
+ ...(tableName ? { tableName } : {}),
+ })
+ }
+ }
+
+ if (WORKFLOW_SCOPED_TOOL_IDS.has(name) && !stringParam(args?.workflowName)) {
+ const workflowName = resolveTargetWorkflowName(args)
+ // Block-scoped tools carry a blockId for the same reason; resolve it too,
+ // so a row says which block ran rather than an opaque id (or nothing).
+ const blockName = stringParam(args?.blockName) ?? resolveBlockNameForDisplay(args?.blockId)
+ const enriched = {
+ ...args,
+ ...(workflowName ? { workflowName } : {}),
+ ...(blockName ? { blockName } : {}),
+ }
+ if (workflowName || blockName) return getToolDisplayTitle(name, enriched)
+ }
+
return getToolDisplayTitle(name, args)
}
@@ -270,11 +366,11 @@ export function resolveStreamingToolDisplayTitle(
name: string,
streamingArgs: string
): string | undefined {
- if (name === FunctionExecute.id) {
+ if (name === RunFunction.id) {
return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title'))
}
- if (name === WorkspaceFile.id) {
+ if (name === PrepareFileEdit.id) {
return resolveWorkspaceFileDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
matchStreamingStringArg(streamingArgs, 'title'),
@@ -282,7 +378,7 @@ export function resolveStreamingToolDisplayTitle(
)
}
- if (name === CreateFile.id) {
+ if (name === CreateEmptyFile.id) {
const target =
matchStreamingStringArg(streamingArgs, 'path') ??
matchStreamingStringArg(streamingArgs, 'fileName')
@@ -299,7 +395,7 @@ export function resolveStreamingToolDisplayTitle(
return workflowId ? resolveToolDisplayTitle(name, { workflowId }) : undefined
}
- if (name === SearchOnline.id) {
+ if (name === WebSearch.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching online for ${toolTitle}` : undefined
}
@@ -349,12 +445,12 @@ export function resolveStreamingToolDisplayTitle(
return toolTitle ? `Deleting ${toolTitle}` : undefined
}
- if (name === ScrapePage.id) {
+ if (name === WebScrape.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Scraping ${url}` : undefined
}
- if (name === CrawlWebsite.id) {
+ if (name === WebCrawl.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Crawling ${url}` : undefined
}
@@ -363,7 +459,7 @@ export function resolveStreamingToolDisplayTitle(
return resolveStreamingManagedResourceTitle(name, streamingArgs, ['toolTitle', 'title', 'name'])
}
- if (name === ManageMcpTool.id) {
+ if (name === ManageMcpConnection.id) {
return resolveStreamingManagedResourceTitle(name, streamingArgs, [
'serverName',
'name',
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
index d4a5b69e3aa..a764b32fa9b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts
@@ -68,7 +68,7 @@ describe('streaming resource titles', () => {
})
// A main-agent file delegation: trigger tool (main lane), subagent span, inner
-// workspace_file, span end, delegation result.
+// prepare_file_edit, span end, delegation result.
function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
const sub: Scope = {
lane: 'subagent',
@@ -89,13 +89,13 @@ function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
env(
4,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
5,
'tool',
- { phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true },
+ { phase: 'result', toolCallId: 'wf-1', toolName: 'prepare_file_edit', success: true },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -124,7 +124,7 @@ describe('modelToContentBlocks', () => {
expect(trigger?.toolCall?.status).toBe('success')
const innerTool = blocksByType(blocks, 'tool_call').find(
- (b) => b.toolCall?.name === 'workspace_file'
+ (b) => b.toolCall?.name === 'prepare_file_edit'
)
expect(innerTool?.spanId).toBe('S1')
expect(innerTool?.toolCall?.calledBy).toBe('file')
@@ -220,7 +220,7 @@ describe('modelToContentBlocks', () => {
env(
4,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -233,7 +233,7 @@ describe('modelToContentBlocks', () => {
])
)
const types = blocks.map((b) => b.type)
- const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file')
+ const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'prepare_file_edit')
const endIdx = types.indexOf('subagent_end')
const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after')
// subagent_end sits after the inner work and before the trailing main text — no sibling jumps.
@@ -275,7 +275,7 @@ describe('modelToContentBlocks', () => {
env(
3,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
@@ -309,7 +309,7 @@ describe('modelToContentBlocks', () => {
env(1, 'tool', {
phase: 'call',
toolCallId: 'wf',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
arguments: { operation: 'create', title: 'My Doc' },
}),
])
@@ -322,7 +322,7 @@ describe('modelToContentBlocks', () => {
const blocks = modelToContentBlocks(build(fileDelegationEvents()))
const startIdx = blocks.findIndex((b) => b.type === 'subagent')
const innerIdx = blocks.findIndex(
- (b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file'
+ (b) => b.type === 'tool_call' && b.toolCall?.name === 'prepare_file_edit'
)
const endIdx = blocks.findIndex((b) => b.type === 'subagent_end')
expect(startIdx).toBeGreaterThanOrEqual(0)
@@ -425,7 +425,7 @@ describe('contentBlocksToModel round-trip', () => {
env(
3,
'tool',
- { phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
+ { phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
index d82bb65783b..6088ee0419a 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts
@@ -167,6 +167,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] {
block: {
type: 'subagent',
content: node.agentId,
+ ...(node.displayName ? { subagentName: node.displayName } : {}),
spanId: node.spanId,
parentSpanId: node.parentSpanId,
...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}),
@@ -267,7 +268,10 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
kind: 'subagent',
event: 'start',
agent: block.content,
- data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {},
+ data: {
+ ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}),
+ ...(block.subagentName ? { name: block.subagentName } : {}),
+ },
},
scopeFor(block),
block.timestamp
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
index 4681ae8e402..85c0b420e73 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts
@@ -144,17 +144,17 @@ describe('reduceEvent — tool lifecycle', () => {
it('accumulates streaming args across deltas', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '{"a":',
}),
envelope(3, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '1}',
}),
])
@@ -164,11 +164,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('clears streamingArgs once the result settles the tool', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
argumentsDelta: '{"operation":"create"',
}),
toolResult(3, 'tc-1', true),
@@ -213,11 +213,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('ignores preview phases (decoupled from tool status)', () => {
const m = apply([
- toolCall(1, 'tc-1', 'workspace_file'),
+ toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
previewPhase: 'file_preview_content',
toolCallId: 'tc-1',
- toolName: 'workspace_file',
+ toolName: 'prepare_file_edit',
content: 'x',
contentMode: 'delta',
fileName: 'f',
@@ -237,6 +237,29 @@ describe('reduceEvent — subagent lifecycle', () => {
expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN)
})
+ it('captures the orchestrator-chosen display name from span start data', () => {
+ const m = apply([
+ envelope(
+ 1,
+ 'span',
+ {
+ kind: 'subagent',
+ event: 'start',
+ agent: 'research',
+ data: { tool_call_id: 'tc-r', name: 'Pricing research' },
+ },
+ {
+ lane: 'subagent',
+ spanId: 'S1',
+ parentSpanId: MAIN_SPAN,
+ parentToolCallId: 'tc-r',
+ agentId: 'research',
+ }
+ ),
+ ])
+ expect(agent(m, 'S1').displayName).toBe('Pricing research')
+ })
+
it('settles an agent error when span end carries an error', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
@@ -270,8 +293,8 @@ describe('reduceEvent — subagent lifecycle', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-a'),
spanStart(2, 'S2', 'file', 'tc-b'),
- toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
- toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }),
+ toolCall(3, 'wf-a', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(4, 'wf-b', 'prepare_file_edit', { lane: 'subagent', spanId: 'S2' }),
toolResult(5, 'wf-a', true),
spanEnd(6, 'S1', 'file'),
toolResult(7, 'wf-b', true),
@@ -327,7 +350,7 @@ describe('reduceEvent — idempotency', () => {
it('rebuilds the identical model when replayed into a fresh model', () => {
const events = [
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(2, 'wf', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
toolResult(3, 'wf', true),
spanEnd(4, 'S1', 'file'),
complete(5),
@@ -340,42 +363,42 @@ describe('reduceEvent — idempotency', () => {
})
})
-describe('reduceEvent — edit_content row merge', () => {
- it('folds an edit_content write into its span workspace_file row', () => {
+describe('reduceEvent — apply_file_edit row merge', () => {
+ it('folds an apply_file_edit write into its span prepare_file_edit row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
- // No separate edit_content node; the workspace_file row reopened for the edit.
+ // No separate apply_file_edit node; the prepare_file_edit row reopened for the edit.
expect(m.nodes.has('ec-1')).toBe(false)
expect(tool(m, 'wf-1').status).toBe('running')
expect(m.toolAlias.get('ec-1')).toBe('wf-1')
})
- it('settles the merged row on the edit_content result', () => {
+ it('settles the merged row on the apply_file_edit result', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
- toolCall(3, 'ec-1', 'edit_content', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
+ toolCall(3, 'ec-1', 'apply_file_edit', sub),
toolResult(4, 'ec-1', true, undefined, sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(m.nodes.has('ec-1')).toBe(false)
})
- it('folds an edit_content result that raced ahead of its call into the merged row', () => {
+ it('folds an apply_file_edit result that raced ahead of its call into the merged row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', sub),
- // Result for edit_content arrives BEFORE its call (buffered under ec-1)...
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
+ // Result for apply_file_edit arrives BEFORE its call (buffered under ec-1)...
toolResult(3, 'ec-1', true, undefined, sub),
// ...then the call lands and aliases ec-1 -> wf-1, draining the buffer.
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(tool(m, 'wf-1').result?.success).toBe(true)
@@ -386,13 +409,13 @@ describe('reduceEvent — edit_content row merge', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- // Section 1: the workspace_file row is reopened by its edit_content, but the
+ // Section 1: the prepare_file_edit row is reopened by its apply_file_edit, but the
// edit's closing result is reordered/dropped — wf-1 is left running.
- toolCall(2, 'wf-1', 'workspace_file', sub),
+ toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
- toolCall(4, 'ec-1', 'edit_content', sub),
+ toolCall(4, 'ec-1', 'apply_file_edit', sub),
// Section 2 opens before section 1's edit result lands.
- toolCall(5, 'wf-2', 'workspace_file', sub),
+ toolCall(5, 'wf-2', 'prepare_file_edit', sub),
])
// The previous section settles instead of spinning until the turn terminal...
expect(tool(m, 'wf-1').status).toBe('success')
@@ -498,7 +521,7 @@ describe('turn-terminal propagation', () => {
// A file subagent opened but no span end arrived (mid-stream error/disconnect).
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
- toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
+ toolCall(2, 'wf-1', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
])
expect(agent(m, 'S1').endSeq).toBeUndefined()
applyTurnTerminal(m, 'error')
@@ -556,3 +579,93 @@ describe('reduceEvent — span-start owner reconciliation', () => {
expect((lane as AgentNode).agentId).toBe('workflow')
})
})
+
+describe('reduceEvent — span end settles stale lane tools', () => {
+ const laneScope = { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } as Scope
+
+ it('marks still-running tools success when their lane ends cleanly', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ // No result for click-1 — dropped/reordered past the lane end.
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: {} },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('success')
+
+ const laneId = model.agentBySpanId.get('S1')
+ const lane = laneId ? model.nodes.get(laneId) : undefined
+ if (lane?.kind !== 'agent') throw new Error('expected agent lane')
+ expect(lane.status).toBe('success')
+ })
+
+ it('marks still-running tools error when the lane ends with an error', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: { error: 'boom' } },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('error')
+ })
+
+ it('leaves settled tools alone and lets a late result overwrite the settle', () => {
+ const model = apply([
+ envelope(
+ 1,
+ 'span',
+ { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } },
+ laneScope
+ ),
+ toolCall(2, 'click-1', 'browser_click', laneScope),
+ envelope(
+ 3,
+ 'span',
+ { kind: 'subagent', event: 'end', agent: 'browser', data: {} },
+ laneScope
+ ),
+ // Late result arrives after the settle — it must win.
+ envelope(
+ 4,
+ 'tool',
+ {
+ phase: 'result',
+ toolCallId: 'click-1',
+ toolName: 'browser_click',
+ success: false,
+ error: 'nope',
+ },
+ laneScope
+ ),
+ ])
+
+ const click = model.nodes.get('click-1')
+ if (click?.kind !== 'tool') throw new Error('expected tool node')
+ expect(click.status).toBe('error')
+ expect(click.result?.error).toBe('nope')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
index 9139fe504ee..6097a22cf76 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
@@ -85,6 +85,8 @@ export interface AgentNode extends NodeBase {
agentId: string
/** The outer delegation tool_use that triggered this run; links the trigger tool node. */
triggerToolCallId?: string
+ /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */
+ displayName?: string
status: NodeStatus
/** Wire seq at which the run terminated (span end), for ordering the close marker. */
endSeq?: number
@@ -121,7 +123,7 @@ export interface TurnModel {
>
/**
* Maps a tool call id to another tool node it folds into. Used for the
- * `edit_content` -> `workspace_file` row merge so the write streams into the
+ * `apply_file_edit` -> `prepare_file_edit` row merge so the write streams into the
* single "writing" row rather than a second row.
*/
toolAlias: Map
@@ -142,16 +144,16 @@ export function createTurnModel(): TurnModel {
}
}
-const WORKSPACE_FILE_TOOL = 'workspace_file'
-const EDIT_CONTENT_TOOL = 'edit_content'
+const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
+const EDIT_CONTENT_TOOL = 'apply_file_edit'
-/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */
+/** Resolves a tool call id through the alias map (e.g. apply_file_edit -> its prepare_file_edit row). */
export function resolveToolId(model: TurnModel, id: string): string {
return model.toolAlias.get(id) ?? id
}
/**
- * Finds the most recent `workspace_file` tool node in a span so an `edit_content`
+ * Finds the most recent `prepare_file_edit` tool node in a span so an `apply_file_edit`
* write folds into it (the single "writing" row). Co-location in the file
* subagent's span is the link — no coupling to preview phases. The caller
* reopens whatever this returns, including an already-settled row (an edit after
@@ -169,11 +171,11 @@ function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode
}
/**
- * The file agent writes a file as strictly sequential `workspace_file` +
- * `edit_content` section pairs, waiting for each to finish before the next. So
- * when a new section's `workspace_file` opens, any earlier `workspace_file` row
+ * The file agent writes a file as strictly sequential `prepare_file_edit` +
+ * `apply_file_edit` section pairs, waiting for each to finish before the next. So
+ * when a new section's `prepare_file_edit` opens, any earlier `prepare_file_edit` row
* still `running` in the same span is a completed section whose closing
- * `edit_content` result was reordered or dropped — finalize it as success so its
+ * `apply_file_edit` result was reordered or dropped — finalize it as success so its
* "writing" spinner resolves when the next section starts, instead of lingering
* until the turn-terminal sweep. A no-op on the happy path (prior rows already
* settled on their own result).
@@ -331,8 +333,8 @@ function appendText(
/**
* Applies a result that raced ahead of its tool `call` (buffered under `fromId`)
* onto `node`, then clears the buffer. Used by the normal call path and by the
- * edit_content -> workspace_file merge, where the buffer is keyed by the
- * edit_content id but folds into the workspace_file row.
+ * apply_file_edit -> prepare_file_edit merge, where the buffer is keyed by the
+ * apply_file_edit id but folds into the prepare_file_edit row.
*/
function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void {
const buffered = model.bufferedResults.get(fromId)
@@ -473,12 +475,12 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
ensureSubagentLane(model, spanId, scope, seq, tsMs)
const phase = payload.phase
if (phase === MothershipStreamV1ToolPhase.call) {
- // edit_content folds into its span's workspace_file row (the write
+ // apply_file_edit folds into its span's prepare_file_edit row (the write
// continues in the single "writing" row), reopening it for the edit.
if (toolName === EDIT_CONTENT_TOOL) {
- // A re-emitted edit_content call (same tool call id — duplicate/replay)
+ // A re-emitted apply_file_edit call (same tool call id — duplicate/replay)
// must keep its ORIGINAL target row. Re-running the span lookup can
- // return a newer workspace_file, and folding into that would leave the
+ // return a newer prepare_file_edit, and folding into that would leave the
// first (already reopened) row running with no result ever closing it —
// a spinner stuck until the turn-terminal sweep. So once aliased, reuse.
const aliasedId = model.toolAlias.get(rawToolCallId)
@@ -492,7 +494,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
parent.status = 'running'
parent.result = undefined
// A result that raced ahead of this call was buffered under the
- // edit_content id; fold it into the reopened workspace_file row.
+ // apply_file_edit id; fold it into the reopened prepare_file_edit row.
drainBufferedResult(model, rawToolCallId, parent)
break
}
@@ -563,6 +565,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
const triggerToolCallId =
scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId)
const agentId = asString(payload.agent) ?? scope?.agentId ?? ''
+ const displayName = asString(data?.name)
const resolvedSpanId =
scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`)
const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN
@@ -581,6 +584,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
// scope.agentId can name the forwarding caller (e.g. superagent),
// while this start's payload.agent is the authoritative lane owner.
if (agentId && existing.agentId !== agentId) existing.agentId = agentId
+ if (displayName) existing.displayName = displayName
if (!existing.triggerToolCallId && triggerToolCallId) {
existing.triggerToolCallId = triggerToolCallId
}
@@ -602,6 +606,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
seq: seq,
...(tsMs !== undefined ? { startedAtMs: tsMs } : {}),
...(triggerToolCallId ? { triggerToolCallId } : {}),
+ ...(displayName ? { displayName } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
@@ -611,10 +616,25 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
if (data?.pending === true) break
breakLane(model, resolvedSpanId, tsMs)
const node = model.nodes.get(resolvedSpanId)
+ const spanErrored = Boolean(data && asString(data.error))
if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) {
- node.status = data && asString(data.error) ? 'error' : 'success'
+ node.status = spanErrored ? 'error' : 'success'
node.endSeq = seq
}
+ // The lane is over: settle any tool row still `running` in it (its
+ // result was dropped or reordered past the end). Left open, the row
+ // pins the whole group expanded and shimmering for the rest of the
+ // turn even though the subagent already returned. A late result event
+ // still corrects this — applyToolResult overwrites unconditionally.
+ for (const id of model.order) {
+ const stale = model.nodes.get(id)
+ if (stale?.kind === 'tool' && stale.spanId === resolvedSpanId) {
+ if (stale.status === 'running') {
+ stale.status = spanErrored ? 'error' : 'success'
+ stale.streamingArgs = undefined
+ }
+ }
+ }
}
break
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
index 281714e81d9..d0212d24b30 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
@@ -12,7 +12,10 @@ import {
getReplayCompletedWorkflowToolCallIds,
panelForExecutingClientTool,
reconcileLiveAssistantTurn,
+ selectDeletedWorkflowResources,
selectReconnectReplayState,
+ shouldActivateResourceEvent,
+ shouldQueueOutgoingMessage,
waitForDetachedChatResolution,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import type {
@@ -30,6 +33,76 @@ vi.mock('next/navigation', () => ({
}),
}))
+describe('selectDeletedWorkflowResources', () => {
+ const resource = (id: string) => ({ type: 'workflow' as const, id, title: id })
+ const cached = (id: string) => ({
+ id,
+ name: id,
+ lastModified: new Date(0),
+ createdAt: new Date(0),
+ sortOrder: 0,
+ })
+
+ it('selects a hydrated workflow the server no longer has', () => {
+ expect(selectDeletedWorkflowResources([resource('wf-gone')], new Set(), [])).toEqual([
+ resource('wf-gone'),
+ ])
+ })
+
+ it('keeps a workflow present in the fetched list', () => {
+ expect(selectDeletedWorkflowResources([resource('wf-1')], new Set(['wf-1']), [])).toEqual([])
+ })
+
+ it('keeps a workflow the stream inserted into the cache after the list snapshot', () => {
+ expect(
+ selectDeletedWorkflowResources([resource('wf-new')], new Set(), [cached('wf-new')])
+ ).toEqual([])
+ })
+})
+
+describe('shouldActivateResourceEvent', () => {
+ it('surfaces browser work even when another resource is selected', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(true)
+ })
+
+ it('surfaces every other resource the agent touches', () => {
+ expect(shouldActivateResourceEvent('file-1', 'workflow-1')).toBe(true)
+ expect(shouldActivateResourceEvent('browser-session', 'terminal-session')).toBe(true)
+ expect(shouldActivateResourceEvent(null, 'browser-session')).toBe(true)
+ })
+
+ it('honors an explicit request to activate', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session', { activate: true })).toBe(true)
+ })
+
+ it('lets an event opt out of stealing focus', () => {
+ expect(shouldActivateResourceEvent('file-1', 'browser-session', { activate: false })).toBe(
+ false
+ )
+ })
+})
+
+describe('shouldQueueOutgoingMessage', () => {
+ it('queues while a send is in flight', () => {
+ expect(shouldQueueOutgoingMessage(true, false, 0)).toBe(true)
+ })
+
+ it('queues while a stop is still settling', () => {
+ expect(shouldQueueOutgoingMessage(false, true, 0)).toBe(true)
+ })
+
+ it('queues behind messages still waiting after the turn ended', () => {
+ // The regression: a message queued mid-stream must dispatch before one
+ // typed in the idle gap after the turn stopped — a direct send here would
+ // jump the queue and swap the user's message order.
+ expect(shouldQueueOutgoingMessage(false, false, 1)).toBe(true)
+ })
+
+ it('sends directly on an idle chat with an empty queue', () => {
+ expect(shouldQueueOutgoingMessage(false, false, 0)).toBe(false)
+ })
+})
+
function userMessage(id: string): PersistedMessage {
return {
id,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index acbea0bd61f..b3e422d5328 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -30,7 +30,7 @@ import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
import {
cancelActiveBrowserTools,
initBrowserAgentTransport,
- sendBrowserPanelAction,
+ openUrlInNewBrowserTab,
} from '@/lib/browser-agent/transport'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
@@ -126,8 +126,10 @@ import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
+import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useExecutionStream } from '@/hooks/use-execution-stream'
+import { snapAllSmoothText } from '@/hooks/use-smooth-text'
import { useExecutionStore } from '@/stores/execution/store'
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
import type {
@@ -228,6 +230,7 @@ const RECONNECT_TAIL_ERROR =
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
+const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000
const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000
const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000
const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000
@@ -1188,8 +1191,65 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId
return true
}
+/**
+ * Hydrated workflow resources whose workflow exists neither in the fetched
+ * server list nor in the local cache. The cache term protects a workflow the
+ * agent created after the list snapshot was taken — the stream's registry
+ * insert lands it in the cache before any refetch does.
+ */
+export function selectDeletedWorkflowResources(
+ workflowResources: MothershipResource[],
+ fetchedWorkflowIds: ReadonlySet,
+ cachedWorkflows: readonly WorkflowMetadata[]
+): MothershipResource[] {
+ const cachedIds = new Set(cachedWorkflows.map((workflow) => workflow.id))
+ return workflowResources.filter(
+ (resource) => !fetchedWorkflowIds.has(resource.id) && !cachedIds.has(resource.id)
+ )
+}
+
+export interface ResourceEventOptions {
+ activate?: boolean
+}
+
+export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void
+
+/**
+ * Whether a streamed resource event should activate its tab. The panel always
+ * follows the agent: whatever it is creating, editing, or driving becomes the
+ * visible resource, browser sessions included. The parameters are retained so
+ * callers stay explicit about the resource in play, and so a future opt-out
+ * (an event that deliberately declines focus) has a place to live.
+ */
+export function shouldActivateResourceEvent(
+ _activeResourceId: string | null,
+ _resourceId: string,
+ options?: ResourceEventOptions
+): boolean {
+ return options?.activate !== false
+}
+
+/**
+ * Whether a fresh outbound message must join the chat's send queue instead of
+ * dispatching directly. Queueing while a send or stop is in flight is the
+ * obvious half; the queued-ahead term preserves FIFO across the
+ * streaming→idle boundary — a message queued while the previous turn streamed
+ * must reach the model before one typed after that turn ended but before the
+ * queue drained. Without it the fresh send jumps the queue and both the
+ * transcript and the model see the user's messages in swapped order. The two
+ * signals never gap mid-dispatch: a queued message stays in the queue until
+ * its optimistic send applies, which is after the in-flight flag is set.
+ */
+export function shouldQueueOutgoingMessage(
+ sendInFlight: boolean,
+ stopPending: boolean,
+ queuedAheadCount: number
+): boolean {
+ return sendInFlight || stopPending || queuedAheadCount > 0
+}
+
export interface UseChatOptions {
- onResourceEvent?: (resourceId: string) => void
+ onResourceEvent?: ResourceEventHandler
apiPath?: string
stopPath?: string
workflowId?: string
@@ -1437,6 +1497,10 @@ export function useChat(
() => {}
)
const recoveringQueuedSendHandoffRef = useRef(null)
+ const recoverActiveStreamRef = useRef<
+ (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck') => Promise
+ >(async () => {})
+ const reconnectExhaustedRecheckTimerRef = useRef | null>(null)
const abortControllerRef = useRef(null)
const detachedChatResolutionControllersRef = useRef>(new Set())
@@ -1814,6 +1878,37 @@ export function useChat(
}
}, [])
+ /**
+ * Drops hydrated workflow tabs whose workflow no longer exists, so an old
+ * chat cannot resurrect a deleted workflow. The check is against a fetched
+ * workflow list rather than the cache: seeding the registry from the chat's
+ * persisted resources (what hydration previously did unconditionally) put
+ * phantom entries in the sidebar that 404 on click. Removal also deletes the
+ * resource from the chat's persisted set, so the tab stays gone next open.
+ */
+ const reconcileHydratedWorkflowResources = useCallback(
+ async (chatId: string, workflowResources: MothershipResource[]) => {
+ let existing: WorkflowMetadata[]
+ try {
+ existing = await getQueryClient().fetchQuery(getWorkflowListQueryOptions(workspaceId))
+ } catch {
+ // Existence is unknowable right now; keep the tabs rather than delete
+ // resources on a network failure. The next hydration retries.
+ return
+ }
+ const deleted = selectDeletedWorkflowResources(
+ workflowResources,
+ new Set(existing.map((workflow) => workflow.id)),
+ getWorkflows(workspaceId)
+ )
+ for (const resource of deleted) {
+ if ((chatIdRef.current ?? selectedChatIdRef.current) !== chatId) return
+ removeResource('workflow', resource.id)
+ }
+ },
+ [workspaceId, removeResource]
+ )
+
const reorderResources = useCallback((newOrder: MothershipResource[]) => {
setResources(newOrder)
const persistChatId = chatIdRef.current ?? selectedChatIdRef.current
@@ -1924,12 +2019,14 @@ export function useChat(
)
const openBrowserResource = useCallback(() => {
+ // Browser work surfaces like any other agent activity: the panel follows
+ // the agent to the browser whether or not the session was already open.
addResource({
type: 'browser',
id: BROWSER_SESSION_RESOURCE_ID,
title: 'Browser',
})
- onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID)
+ onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID, { activate: true })
}, [addResource])
const getResourceActivityTracker = useCallback(
@@ -2047,7 +2144,11 @@ export function useChat(
useEffect(() => {
return onOpenInBrowserPanel((url) => {
openBrowserResource()
- sendBrowserPanelAction('navigate', { url }, desktopScopeIdRef.current)
+ void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => {
+ logger.warn('Failed to open chat link in a new browser tab', {
+ error: getErrorMessage(error),
+ })
+ })
})
}, [openBrowserResource])
@@ -2355,9 +2456,12 @@ export function useChat(
setActiveResourceId(hydratedActiveResourceId)
}
- for (const resource of persistedResources) {
- if (resource.type !== 'workflow') continue
- ensureWorkflowInRegistry(resource.id, resource.title, workspaceId)
+ // Restored workflow tabs are verified against the server instead of
+ // seeded into the registry: a chat can outlive its workflows, and
+ // fabricating entries for deleted ones polluted the sidebar.
+ const workflowResources = persistedResources.filter((r) => r.type === 'workflow')
+ if (workflowResources.length > 0) {
+ void reconcileHydratedWorkflowResources(chatHistory.id, workflowResources)
}
} else if (hasPersistedStreamingFile) {
activeResourceIdRef.current = null
@@ -2442,6 +2546,7 @@ export function useChat(
flushPendingResources,
openBrowserResource,
openTerminalResource,
+ reconcileHydratedWorkflowResources,
recoverPendingClientWorkflowTools,
seedPreviewSessions,
setTransportIdle,
@@ -3199,7 +3304,29 @@ export function useChat(
maxAttempts: MAX_RECONNECT_ATTEMPTS,
})
if (streamGenRef.current === gen) {
+ /**
+ * Never give up silently: surface the failure so the pane shows why
+ * the live stream stopped instead of a torn-down transcript. Callers
+ * own the finalize on a false return (every call site finalizes with
+ * error: true), which refetches the persisted transcript; if the
+ * server turn is still running, the visibility/online recovery path
+ * re-attaches on the next pageshow/visible/online event.
+ */
setIsReconnecting(false)
+ setError(RECONNECT_TAIL_ERROR)
+ /**
+ * The tab may stay visible (no pageshow/visible/online event will ever
+ * fire) while the server turn keeps running detached. One bounded
+ * recheck re-enters recovery once the transient network condition has
+ * had time to clear; recovery itself no-ops when nothing is active.
+ */
+ if (reconnectExhaustedRecheckTimerRef.current) {
+ clearTimeout(reconnectExhaustedRecheckTimerRef.current)
+ }
+ reconnectExhaustedRecheckTimerRef.current = setTimeout(() => {
+ reconnectExhaustedRecheckTimerRef.current = null
+ void recoverActiveStreamRef.current('exhausted_recheck')
+ }, RECONNECT_EXHAUSTED_RECHECK_MS)
}
return false
},
@@ -3208,7 +3335,7 @@ export function useChat(
retryReconnectRef.current = retryReconnect
const recoverActiveStreamFromRedis = useCallback(
- async (reason: 'pageshow' | 'visible' | 'online'): Promise => {
+ async (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck'): Promise => {
const startingChatId = chatIdRef.current
const startingSelectedChatId = selectedChatIdRef.current
const chatId = startingChatId ?? startingSelectedChatId
@@ -3343,6 +3470,7 @@ export function useChat(
},
[getActiveStreamIdForChat, queryClient, resumeOrFinalize, setTransportReconnecting]
)
+ recoverActiveStreamRef.current = recoverActiveStreamFromRedis
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return
@@ -3374,6 +3502,10 @@ export function useChat(
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener('online', handleOnline)
+ if (reconnectExhaustedRecheckTimerRef.current) {
+ clearTimeout(reconnectExhaustedRecheckTimerRef.current)
+ reconnectExhaustedRecheckTimerRef.current = null
+ }
}
}, [recoverActiveStreamFromRedis])
@@ -4164,12 +4296,23 @@ export function useChat(
// An in-flight send drains the queue from `finalize`; a pending stop kicks
// the dispatcher itself, since nothing else will once the stop settles.
- if (sendingRef.current || pendingStopPromiseRef.current) {
+ // A non-empty queue forces queueing even on an idle chat: messages
+ // queued while the previous turn streamed must go out first, so a fresh
+ // send lands behind them instead of jumping the line in the drain gap
+ // after a turn ends.
+ const queuedAheadCount = (queueStore.queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE).length
+ if (
+ shouldQueueOutgoingMessage(
+ Boolean(sendingRef.current),
+ Boolean(pendingStopPromiseRef.current),
+ queuedAheadCount
+ )
+ ) {
queueStore.enqueue(
activeChatKey,
createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId)
)
- if (pendingStopPromiseRef.current) {
+ if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) {
void enqueueQueueDispatchRef.current({ type: 'send_head' })
}
return
@@ -4518,6 +4661,9 @@ export function useChat(
abortControllerRef.current?.abort('user_stop:client_stopGeneration')
abortControllerRef.current = null
setTransportIdle()
+ // The paced reveal may still hold up to a drain-horizon of buffered text;
+ // after an explicit Stop it must not keep typing itself out.
+ snapAllSmoothText()
try {
if (activeChatId) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts
index e6d21c27765..9634055b5e5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts
@@ -1,7 +1,7 @@
import type { ChatContext } from '@/stores/panel'
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
-const EDIT_CONTENT_TOOL_ID = 'edit_content'
+const EDIT_CONTENT_TOOL_ID = 'apply_file_edit'
const RUN_SUBAGENT_ID = 'run'
export type {
@@ -114,6 +114,8 @@ export interface ContentBlock {
type: ContentBlockType
content?: string
subagent?: string
+ /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */
+ subagentName?: string
toolCall?: ToolCallInfo
options?: OptionItem[]
timestamp?: number
@@ -189,9 +191,13 @@ export const SUBAGENT_LABELS: Record = {
custom_tool: 'Custom Tool Agent',
scout: 'Scout Agent',
search: 'Search Agent',
+ platform: 'Platform Agent',
superagent: 'Superagent',
run: 'Run Agent',
- agent: 'Tools Agent',
+ // The extensions subagent's wire/scope AgentID stays `agent` (pre-rename);
+ // `extensions` is its current model-facing trigger tool name.
+ agent: 'Extensions Agent',
+ extensions: 'Extensions Agent',
// `job` retained as a backward-compat alias so historical transcripts still render a label.
job: 'Job Agent',
file: 'File Agent',
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
index 449e1dbe83a..cf0a76bcac1 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx
@@ -29,6 +29,7 @@ import {
SLACK_CAPABILITIES,
SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY,
} from '@/triggers/slack/capabilities'
+import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
const logger = createLogger('ConnectSlackBotModal')
@@ -119,13 +120,9 @@ export function ConnectSlackBotModal({
}
}, [open, created, isReconnect, initialDisplayName, initialDescription])
- // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
- // able to reach this URL, so it has to be the app's public base (e.g. the
- // tunnel host in dev), not whatever host the browser happens to be on.
- const requestUrl = useMemo(
- () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
- [credentialId]
- )
+ // Shared server-side derivation: uses the app public base (not
+ // window.location.origin) so Slack's servers can reach it.
+ const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId])
const manifestJson = useMemo(() => {
const managedUserAuthorization = selected.has(SLACK_MANAGED_USER_AUTHORIZATION_CAPABILITY.id)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 5c9e0e4d99d..c920ec3105b 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -29,7 +29,11 @@ import type {
SelectableConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
-import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components'
+import {
+ EMPTY_CELL_PLACEHOLDER,
+ Resource,
+ SearchHighlight,
+} from '@/app/workspace/[workspaceId]/components'
import {
FOLDERED_RESOURCE_HEADERS,
folderBreadcrumbItems,
@@ -47,7 +51,7 @@ import {
documentParsers,
documentUrlKeys,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params'
-import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
+import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index a01bb376e57..e40108c2184 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -62,6 +62,7 @@ import {
FILTER_SECTION_LABEL_CLASS,
FloatingOverflowText,
Resource,
+ SearchHighlight,
} from '@/app/workspace/[workspaceId]/components'
import {
FOLDERED_RESOURCE_HEADERS,
@@ -78,7 +79,6 @@ import {
ConnectorsSection,
DocumentContextMenu,
RenameDocumentModal,
- SearchHighlight,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
addConnectorParam,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
index 12e32ebf736..d26e85dc9e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts
@@ -6,4 +6,3 @@ export { ConnectorsSection } from './connectors-section'
export { DocumentContextMenu } from './document-context-menu'
export { EditConnectorModal } from './edit-connector-modal'
export { RenameDocumentModal } from './rename-document-modal'
-export { SearchHighlight } from './search-highlight'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts
deleted file mode 100644
index 1144ed165cd..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SearchHighlight } from './search-highlight'
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
index 327771b3a5b..bb3e1a1109e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx
@@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({
),
Label: ({ children }: { children: ReactNode }) => {children},
- Switch: ({ checked }: { checked: boolean }) => (
-
+ Switch: ({
+ checked,
+ disabled,
+ onCheckedChange,
+ }: {
+ checked: boolean
+ disabled?: boolean
+ onCheckedChange?: (checked: boolean) => void
+ }) => (
+
+ {supportsSearchSuggestions && (
+
+
+
+
+ Send address-bar typing to Google for live completions
+
+
+
void setSearchSuggestionsEnabled(checked)}
+ />
+
+ )}
+
{findOpen && (
-
view.id === activeViewId)
if (activeViewId === null || inheritedParams) {
- const defaultView = views.find((view) => view.isDefault)
+ const pinnedView =
+ embedded && initialViewId ? views.find((view) => view.id === initialViewId) : undefined
+ const defaultView = pinnedView ?? views.find((view) => view.isDefault)
// `sort` rides the same host URL, so when the view id is inherited the
// sort beside it is too — not local work, and it must not suppress the
// default view's own sort.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
index 3f888b2e19c..4dc59961db6 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts
@@ -1070,7 +1070,10 @@ export async function executeWorkflowWithFullLogging(
error: errorMessage,
httpStatus: response.status,
})
- throw new Error(errorMessage)
+ // Keep the status and code on the thrown error. Downgrading to a bare Error
+ // discarded both, so callers could not tell a Copilot binding rejection from
+ // any other 4xx — and the reason never reached the agent that could fix it.
+ throw new ExecutionStreamHttpError(errorMessage, response.status, errorCode)
}
if (!response.body) {
diff --git a/apps/sim/components/icons/document-icons.tsx b/apps/sim/components/icons/document-icons.tsx
index 6b669a44d6f..cd4436318d6 100644
--- a/apps/sim/components/icons/document-icons.tsx
+++ b/apps/sim/components/icons/document-icons.tsx
@@ -139,6 +139,26 @@ export function VideoIcon(props: SVGProps) {
)
}
+export function ChartFileIcon(props: SVGProps) {
+ return (
+
+ )
+}
+
export function HtmlIcon(props: SVGProps) {
return (