Skip to content

Commit bf40b95

Browse files
committed
fix(desktop): settle browser tab sync edge cases and ease the tab width cap
Review and in-app verification of the resource-tab browser model found five problems, all fixed here with tests: - The strip's switch and follow effects could trade `switch-tab` calls forever after a native switch; the switch effect is now keyed on the selection alone and ignores the switch it requested itself. - Migrating a pending chat onto its durable id briefly removed and re-added every browser tab; a missing store bucket is no longer read as closed. - Chat hydration could replace the resource list underneath a fresh add, dropping restored tabs on return; a tab stays projected until its resource is seen. Hydration also falls back to the last server-held resource instead of whichever tab happened to land first. - Strip-driven `switch-tab` no longer claims the page for the user (`claim: false`), so the agent can still close or adopt it; a user closing the agent's tab leaves the agent cursor unset rather than announcing the neighbour as agent activity. - The driver marked every restored scope as material, refusing pending chat migration after an empty restore; only a restore that yields pages does now, matching the session layer. The bridge snapshot is regenerated with the documented `'new-tab'` fallback. Resource tab titles ellipsize at 200px for one or two tabs, 180px for three, and 160px from four on, one small step at a time.
1 parent e9441ed commit bf40b95

17 files changed

Lines changed: 465 additions & 130 deletions

File tree

apps/desktop/src/main/browser-agent/driver.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,6 +1277,18 @@ describe('executeTool', () => {
12771277
expect(driver.migrateBrowserScope('pending:other-chat', 'chat-occupied')).toBe(false)
12781278
})
12791279

1280+
it('keeps a durable destination adoptable after an empty restore', async () => {
1281+
await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
1282+
driver.activateBrowserScope('chat-real')
1283+
expect(driver.restoreBrowserScope('chat-real')).toMatchObject({ tabs: [] })
1284+
1285+
expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true)
1286+
await expect(driver.executeTool('chat-real', 'browser_list_tabs', {})).resolves.toMatchObject({
1287+
ok: true,
1288+
result: { scopeId: 'chat-real', tabs: [{ tabId: '1' }] },
1289+
})
1290+
})
1291+
12801292
it('cancels only the replaced destination authorizations during migration', async () => {
12811293
await driver.executeTool('pending:new-chat', 'browser_open_tab', {})
12821294
driver.activateBrowserScope('chat-real')

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -794,10 +794,13 @@ export function restoreBrowserScope(scopeId: string): BrowserTabsState {
794794
return session.withBrowserScope(resolved, () => session.peekTabsState())
795795
}
796796
const state = driverScopeState(resolved)
797-
state.activationOnly = false
798797
return session.withBrowserScope(resolved, () => {
799798
session.restoreBrowserSession()
800-
return session.peekTabsState()
799+
const tabs = session.peekTabsState()
800+
// Only a scope that actually holds pages is material; one restored empty
801+
// stays adoptable by a pending chat migrating onto its id.
802+
if (tabs.tabs.length > 0) state.activationOnly = false
803+
return tabs
801804
})
802805
}
803806

@@ -4783,7 +4786,7 @@ export async function handlePanelAction(
47834786
}
47844787
if (action.action === 'switch-tab') {
47854788
if (typeof action.tabId === 'string') {
4786-
session.switchTab(action.tabId)
4789+
session.switchTab(action.tabId, { claim: action.claim !== false })
47874790
}
47884791
return
47894792
}

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3152,7 +3152,12 @@ export function reopenClosedTab(): AgentTab | null {
31523152
return tab
31533153
}
31543154

3155-
export function switchTab(tabId: string): AgentTab {
3155+
/**
3156+
* Shows a tab. `claim` records the visible page as the user's own; a switch
3157+
* that only mirrors the renderer's strip selection passes false so the agent
3158+
* can still close or adopt the page as its own.
3159+
*/
3160+
export function switchTab(tabId: string, { claim = true }: { claim?: boolean } = {}): AgentTab {
31563161
restoreBrowserSession()
31573162
const tab = tabs.find((entry) => entry.id === tabId)
31583163
if (!tab) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
@@ -3168,7 +3173,7 @@ export function switchTab(tabId: string): AgentTab {
31683173
revokeTabMediaPermissions(previousActiveTab, false)
31693174
}
31703175
currentScope.activeTabId = tab.id
3171-
currentScope.visibleTabUserSelected = true
3176+
if (claim) currentScope.visibleTabUserSelected = true
31723177
promotePendingTabRestore(tab)
31733178
// Visible selection does not move the automation exemption; the user may
31743179
// inspect another page while a tool continues in its background tab.
@@ -3192,7 +3197,15 @@ export function switchAutomationTab(tabId: string): AgentTab {
31923197
return tab
31933198
}
31943199

3195-
export function closeTab(tabId: string): void {
3200+
/**
3201+
* Closes a tab. When the agent closes its own working tab it moves on to the
3202+
* neighbour so its next page tool has a target; a close the user made leaves
3203+
* the agent cursor unset instead of announcing a page the agent never chose.
3204+
*/
3205+
export function closeTab(
3206+
tabId: string,
3207+
{ adoptNeighborForAgent = false }: { adoptNeighborForAgent?: boolean } = {}
3208+
): void {
31963209
restoreBrowserSession()
31973210
const index = tabs.findIndex((entry) => entry.id === tabId)
31983211
if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
@@ -3221,7 +3234,9 @@ export function closeTab(tabId: string): void {
32213234
}
32223235
}
32233236
if (currentScope.automationTabId === tab.id) {
3224-
currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null
3237+
currentScope.automationTabId = adoptNeighborForAgent
3238+
? ((tabs[index] ?? tabs[index - 1])?.id ?? null)
3239+
: null
32253240
applyActiveTabThrottling()
32263241
}
32273242
if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId
@@ -3240,7 +3255,7 @@ export function closeAutomationTab(tabId: string): void {
32403255
'That tab is currently being used by the user. Switch to another agent tab instead of closing it.'
32413256
)
32423257
}
3243-
closeTab(tabId)
3258+
closeTab(tabId, { adoptNeighborForAgent: true })
32443259
}
32453260

32463261
/** The live page whose browser surface owns a menu accelerator. */

apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
RESOURCE_HEADER_CLASSES,
2828
RESOURCE_TAB_ICON_BUTTON_CLASS,
2929
RESOURCE_TAB_ICON_CLASS,
30+
resourceTabWidthClass,
3031
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
3132

3233
export type HeroResourceId = 'workflow' | 'table' | 'brief'
@@ -155,7 +156,7 @@ export function HeroResourcePanel({
155156
onSelect={(id) => onActiveChange(id as HeroResourceId)}
156157
onClose={(id) => onCloseResource(id as HeroResourceId)}
157158
variant='floating'
158-
className={RESOURCE_HEADER_CLASSES.stripGeometry}
159+
className={cn(RESOURCE_HEADER_CLASSES.stripGeometry, resourceTabWidthClass(tabs.length))}
159160
newTabControl={
160161
<DropdownMenu>
161162
<Tooltip.Root>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -653,9 +653,9 @@ export function BrowserSession({
653653

654654
useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId])
655655

656-
// A fresh blank tab coming on screen — opened from the resource strip, by
657-
// Cmd+T, or by the shell replacing a closed last tab — gets the omnibox, the
658-
// way Chrome's new-tab page does. A tab with a page keeps its content.
656+
// A fresh blank tab coming on screen — opened from the resource strip or by
657+
// Cmd+T — gets the omnibox, the way Chrome's new-tab page does. A tab with a
658+
// page keeps its content.
659659
const focusedBlankTabIdRef = useRef<string | null>(null)
660660
useEffect(() => {
661661
if (!visible || !activeTabId || !showEmptyState) return

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { useBrowserSessionStore } from '@/stores/browser-session/store'
1111
interface BrowserTabIconProps {
1212
/** Native tab id, which is also the browser resource's id. */
1313
tabId: string
14+
/** Desktop browser scope the tab lives in; without one the icon is a plain globe. */
15+
scopeId?: string
1416
className?: string
1517
}
1618

@@ -21,29 +23,32 @@ interface BrowserTabIconProps {
2123
* thinking loader, so the strip shows where the agent is without pulling the
2224
* user's selection there.
2325
*/
24-
export function BrowserTabIcon({ tabId, className }: BrowserTabIconProps) {
25-
const tab = useBrowserSessionStore((state) => {
26-
const scopeId = state.activeScopeId
27-
return scopeId
28-
? state.sessions[scopeId]?.tabs.find((entry) => entry.tabId === tabId)
29-
: undefined
30-
})
26+
export function BrowserTabIcon({ tabId, scopeId, className }: BrowserTabIconProps) {
27+
const tab = useBrowserSessionStore((state) =>
28+
scopeId ? state.sessions[scopeId]?.tabs.find((entry) => entry.tabId === tabId) : undefined
29+
)
3130
const agentWorking = useBrowserSessionStore((state) => {
32-
const scopeId = state.activeScopeId
3331
const session = scopeId ? state.sessions[scopeId] : undefined
3432
return Boolean(
3533
session &&
3634
session.automationTabId === tabId &&
3735
(session.automationActive || session.agentRunIds.length > 0)
3836
)
3937
})
40-
const [loadedHostname, setLoadedHostname] = useState<string | null>(null)
41-
const [failedHostname, setFailedHostname] = useState<string | null>(null)
38+
/** Outcome of the favicon request for one hostname; the image remounts per host. */
39+
const [favicon, setFavicon] = useState<{ hostname: string; status: 'loaded' | 'failed' } | null>(
40+
null
41+
)
4242

4343
const hostname = tab ? browserTabHostname(tab.url) : null
44-
const faviconLoaded = Boolean(hostname && loadedHostname === hostname)
45-
const faviconFailed = Boolean(hostname && failedHostname === hostname)
46-
const showSpinner = shouldShowBrowserTabSpinner(tab?.loading ?? false, hostname, loadedHostname)
44+
const faviconStatus = hostname && favicon?.hostname === hostname ? favicon.status : null
45+
const faviconLoaded = faviconStatus === 'loaded'
46+
const faviconFailed = faviconStatus === 'failed'
47+
const showSpinner = shouldShowBrowserTabSpinner(
48+
tab?.loading ?? false,
49+
hostname,
50+
faviconLoaded ? hostname : null
51+
)
4752

4853
return (
4954
<span className={cn('relative flex items-center justify-center', className)}>
@@ -62,8 +67,8 @@ export function BrowserTabIcon({ tabId, className }: BrowserTabIconProps) {
6267
'size-[16px] rounded-[3px]',
6368
!faviconLoaded && 'pointer-events-none absolute opacity-0'
6469
)}
65-
onLoad={() => setLoadedHostname(hostname)}
66-
onError={() => setFailedHostname(hostname)}
70+
onLoad={() => setFavicon({ hostname, status: 'loaded' })}
71+
onError={() => setFavicon({ hostname, status: 'failed' })}
6772
/>
6873
)}
6974
{showSpinner ? (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export interface ResourceTypeConfig {
4040
type: MothershipResourceType
4141
label: string
4242
icon: ElementType
43-
renderTabIcon: (resource: MothershipResource, className: string) => ReactNode
43+
/** `desktopScopeId` names the desktop browser scope a browser tab belongs to. */
44+
renderTabIcon: (
45+
resource: MothershipResource,
46+
className: string,
47+
desktopScopeId?: string
48+
) => ReactNode
4449
renderDropdownItem: (props: DropdownItemRenderProps) => ReactNode
4550
/**
4651
* How many of this family's candidates an unfiltered `@` list shows, overriding
@@ -234,8 +239,8 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
234239
type: 'browser',
235240
label: 'Browser',
236241
icon: Globe,
237-
renderTabIcon: (resource, className) => (
238-
<BrowserTabIcon tabId={resource.id} className={className} />
242+
renderTabIcon: (resource, className, desktopScopeId) => (
243+
<BrowserTabIcon tabId={resource.id} scopeId={desktopScopeId} className={className} />
239244
),
240245
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Globe} />,
241246
},

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from 'vitest'
2-
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
2+
import {
3+
RESOURCE_HEADER_CLASSES,
4+
resourceTabWidthClass,
5+
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
36

47
describe('resource header geometry', () => {
58
it('moves the action cluster beside the collapse target without moving the toggle', () => {
@@ -9,4 +12,13 @@ describe('resource header geometry', () => {
912
expect(RESOURCE_HEADER_CLASSES.layout).toContain('[--resource-header-toggle-size:30px]')
1013
expect(RESOURCE_HEADER_CLASSES.endPosition).toBe('right-[var(--resource-header-end-inset)]')
1114
})
15+
16+
it('tightens the title cap one small step per added tab down to the floor', () => {
17+
expect(RESOURCE_HEADER_CLASSES.stripGeometry).not.toContain('--tab-strip-max-tab-width')
18+
expect(resourceTabWidthClass(1)).toBe('[--tab-strip-max-tab-width:200px]')
19+
expect(resourceTabWidthClass(2)).toBe('[--tab-strip-max-tab-width:200px]')
20+
expect(resourceTabWidthClass(3)).toBe('[--tab-strip-max-tab-width:180px]')
21+
expect(resourceTabWidthClass(4)).toBe('[--tab-strip-max-tab-width:160px]')
22+
expect(resourceTabWidthClass(9)).toBe('[--tab-strip-max-tab-width:160px]')
23+
})
1224
})

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ export const RESOURCE_HEADER_CLASSES = {
2727
* keeps: a tab paints a fill, so its box is visible and wants air around it,
2828
* where the toggle and the action buttons are bare glyphs whose box only shows
2929
* on hover.
30+
*
31+
* The tab width cap is chosen per strip from the tab count (see
32+
* {@link resourceTabWidthClass}), so a title can breathe while the strip is
33+
* roomy and only tightens once the tabs start competing for the width.
3034
*/
3135
stripGeometry:
32-
'[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-band:26px] [--tab-strip-max-tab-width:160px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
36+
'[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-band:26px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]',
3337
/**
3438
* Centred, matching the `floating` strip: its tabs and controls sit centred in
3539
* the header band rather than hanging from the top, so an overlaid control has
@@ -45,3 +49,16 @@ export const RESOURCE_HEADER_CLASSES = {
4549
'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-hit-size)_+_1px)]',
4650
emptyAddOffset: '-translate-x-1.5',
4751
} as const
52+
53+
/**
54+
* Width cap for the strip's tabs, from the tab count. A couple of tabs have
55+
* the room to show more of their titles; each added tab tightens the cap by a
56+
* step small enough to pass unnoticed, down to the floor a full strip needs so
57+
* every tab stays visible for longer before the strip scrolls. Only the
58+
* ellipsis point moves — a title that already fits never changes width.
59+
*/
60+
export function resourceTabWidthClass(tabCount: number): string {
61+
if (tabCount <= 2) return '[--tab-strip-max-tab-width:200px]'
62+
if (tabCount === 3) return '[--tab-strip-max-tab-width:180px]'
63+
return '[--tab-strip-max-tab-width:160px]'
64+
}

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
RESOURCE_HEADER_CLASSES,
3535
RESOURCE_TAB_ICON_BUTTON_CLASS,
3636
RESOURCE_TAB_ICON_CLASS,
37+
resourceTabWidthClass,
3738
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
3839
import type {
3940
MothershipResource,
@@ -254,26 +255,25 @@ export function ResourceTabs({
254255

255256
// A browser tab's title is the live page title, owned by the desktop app.
256257
const browserTabs = useBrowserSessionStore((state) => state.sessions[desktopScopeId]?.tabs)
257-
const browserTitles = useMemo(
258-
() => new Map(browserTabs?.map((tab) => [tab.tabId, browserTabTitle(tab)])),
259-
[browserTabs]
260-
)
261258

262-
const tabs = useMemo<TabStripItem[]>(
263-
() =>
264-
resources.map((resource) => ({
265-
id: resource.id,
266-
title:
267-
(resource.type === 'browser'
268-
? browserTitles.get(resource.id)
269-
: nameLookup.get(`${resource.type}:${resource.id}`)) ?? resource.title,
270-
icon: getResourceConfig(resource.type).renderTabIcon(resource, 'size-[16px] shrink-0'),
271-
active: activeId === resource.id,
272-
selected: selectedIds.size > 1 && selectedIds.has(resource.id),
273-
attention: activityIds?.has(resource.id) ?? false,
274-
})),
275-
[resources, nameLookup, browserTitles, activeId, selectedIds, activityIds]
276-
)
259+
const tabs = useMemo<TabStripItem[]>(() => {
260+
const browserTitles = new Map(browserTabs?.map((tab) => [tab.tabId, browserTabTitle(tab)]))
261+
return resources.map((resource) => ({
262+
id: resource.id,
263+
title:
264+
(resource.type === 'browser'
265+
? browserTitles.get(resource.id)
266+
: nameLookup.get(`${resource.type}:${resource.id}`)) ?? resource.title,
267+
icon: getResourceConfig(resource.type).renderTabIcon(
268+
resource,
269+
'size-[16px] shrink-0',
270+
desktopScopeId
271+
),
272+
active: activeId === resource.id,
273+
selected: selectedIds.size > 1 && selectedIds.has(resource.id),
274+
attention: activityIds?.has(resource.id) ?? false,
275+
}))
276+
}, [resources, nameLookup, browserTabs, desktopScopeId, activeId, selectedIds, activityIds])
277277

278278
const handleAdd = useCallback(
279279
(resource: MothershipResource) => {
@@ -478,7 +478,7 @@ export function ResourceTabs({
478478
onReorder={handleReorder}
479479
onTabDragStart={handleTabDragStart}
480480
variant='floating'
481-
className={RESOURCE_HEADER_CLASSES.stripGeometry}
481+
className={cn(RESOURCE_HEADER_CLASSES.stripGeometry, resourceTabWidthClass(resources.length))}
482482
newTabControl={
483483
// Offered before the chat exists too: a resource opened while composing
484484
// the first prompt is context for that prompt, and gating on a chat id

0 commit comments

Comments
 (0)