Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,21 @@ export function ResourceTabs({

const handleClose = useCallback(
(id: string) => {
const resource = resources.find((r) => r.id === id)
const index = resources.findIndex((r) => r.id === id)
const resource = resources[index]
if (!resource) return
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
if (!confirmClosingRunningTerminals(targets, terminalTabs)) return
// Closing the shown tab moves to its neighbour, right then left, so the
// strip does not fall back to its last tab and jump. For a desktop tab
// this is also the neighbour the desktop app itself picks.
if (!isMulti && activeId === resource.id) {
const sameKind = new Set(resources.filter((r) => r.type === resource.type).map((r) => r.id))
const nextId =
findNearestId(resources, index, sameKind) ?? findNearestId(resources, index, null)
if (nextId) selectResource(nextId)
}
// A browser tab's page is closed natively and its resource dropped at
// once; the tab list then confirms the removal. A shell's close answers
// with the tab list, so its resource follows that list instead — a
Expand Down Expand Up @@ -451,7 +461,16 @@ export function ResourceTabs({
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chatId, desktopScopeId, onRemoveResource, resources, selectedIds, terminalTabs]
[
activeId,
chatId,
desktopScopeId,
onRemoveResource,
resources,
selectResource,
selectedIds,
terminalTabs,
]
)

/**
Expand Down
24 changes: 9 additions & 15 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
const resourceSelectionOwnedByUserRef = useRef(false)

function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) {
const activeResourceId = activeResourceParamRef.current
const activeResourceId = effectiveActiveResourceIdRef.current
const presentation = resolveResourceEventPresentation({
activeResourceId,
activationRequested: shouldActivateResourceEvent(activeResourceId, resourceId, options),
Expand Down Expand Up @@ -317,7 +317,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
const expandResource = () => {
resourceCollapseOwnedByUserRef.current = false
resourceSelectionOwnedByUserRef.current = true
const activeResourceId = activeResourceParamRef.current
const activeResourceId = effectiveActiveResourceIdRef.current
if (activeResourceId) clearResourceActivity(activeResourceId)
setResourceCollapsed(false)
}
Expand All @@ -334,24 +334,18 @@ export function Home({ chatId, userName, userId }: HomeProps) {
[setActiveResourceId, clearResourceActivity]
)

const desktopTabResourceCallbacks = {
const desktopTabResourceOptions = {
scopeId: desktopScopeId,
resources,
activeResourceId,
selectedResourceId: activeResourceParam,
addResource,
removeResource,
selectResource: selectResourceFromUser,
onResourceEvent: handleResourceEvent,
}
useBrowserTabResources({
scopeId: desktopScopeId,
resources,
activeResourceId,
...desktopTabResourceCallbacks,
})
useTerminalTabResources({
scopeId: desktopScopeId,
resources,
activeResourceId,
...desktopTabResourceCallbacks,
})
useBrowserTabResources(desktopTabResourceOptions)
useTerminalTabResources(desktopTabResourceOptions)

const addResourceFromUser = useCallback(
(resource: MothershipResource) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources'
import type { DesktopTabResourceOptions } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useBrowserSessionStore } from '@/stores/browser-session/store'

const { sendBrowserPanelAction, openUrlInNewBrowserTab, openInPanelListeners } = vi.hoisted(() => ({
Expand Down Expand Up @@ -37,17 +38,7 @@ function pushTabs(scopeId: string, tabs: ReturnType<typeof tab>[], activeTabId:
})
}

interface HostProps {
scopeId: string
resources: MothershipResource[]
activeResourceId: string | null
addResource: (resource: MothershipResource) => void
removeResource: (type: MothershipResource['type'], id: string) => void
selectResource: (id: string) => void
onResourceEvent: (id: string, options?: { activate?: boolean }) => void
}

function Host(props: HostProps) {
function Host(props: DesktopTabResourceOptions) {
useBrowserTabResources(props)
return null
}
Expand All @@ -60,19 +51,25 @@ describe('useBrowserTabResources', () => {
const selectResource = vi.fn()
const onResourceEvent = vi.fn()

function render(overrides: Partial<HostProps> = {}) {
const props: HostProps = {
function render(overrides: Partial<DesktopTabResourceOptions> = {}) {
const props: DesktopTabResourceOptions = {
scopeId: SCOPE,
resources: [],
activeResourceId: null,
selectedResourceId: null,
addResource,
removeResource,
selectResource,
onResourceEvent,
...overrides,
}
act(() => root.render(<Host {...props} />))
return (next: Partial<HostProps>) => act(() => root.render(<Host {...props} {...next} />))
/** `alsoInThisCommit` lands a store push and the new props together. */
return (next: Partial<DesktopTabResourceOptions>, alsoInThisCommit?: () => void) =>
act(() => {
alsoInThisCommit?.()
root.render(<Host {...props} {...next} />)
})
}

beforeEach(() => {
Expand Down Expand Up @@ -153,11 +150,11 @@ describe('useBrowserTabResources', () => {
{ type: 'browser', id: '1', title: 'Page 1' },
{ type: 'browser', id: '2', title: 'Page 2' },
]
const rerender = render({ resources, activeResourceId: '1' })
const rerender = render({ resources, activeResourceId: '1', selectedResourceId: '1' })
pushTabs(SCOPE, [tab('1', true), tab('2')], '1')
expect(sendBrowserPanelAction).not.toHaveBeenCalled()

rerender({ activeResourceId: '2' })
rerender({ activeResourceId: '2', selectedResourceId: '2' })
expect(sendBrowserPanelAction).toHaveBeenCalledExactlyOnceWith(
'switch-tab',
{ tabId: '2', claim: false },
Expand All @@ -169,21 +166,70 @@ describe('useBrowserTabResources', () => {
expect(selectResource).not.toHaveBeenCalled()
})

it('shows a page selected before the pages landed, once it arrives', () => {
render({ selectedResourceId: '2', activeResourceId: '2' })
expect(sendBrowserPanelAction).not.toHaveBeenCalled()

pushTabs(SCOPE, [tab('1', true), tab('2')], '1')
expect(sendBrowserPanelAction).toHaveBeenCalledExactlyOnceWith(
'switch-tab',
{ tabId: '2', claim: false },
SCOPE
)
})

it('does not claim the scope first report as a user switch', () => {
const resources: MothershipResource[] = [
{ type: 'browser', id: '1', title: 'Page 1' },
{ type: 'browser', id: '2', title: 'Page 2' },
]
const rerender = render()
pushTabs(SCOPE, [tab('1'), tab('2')], null)
rerender({ resources, activeResourceId: '2', selectedResourceId: null })

// The desktop app reports the page it restored. The strip resolves to that
// page on its own, so there is nothing here to claim for the user.
pushTabs(SCOPE, [tab('1', true), tab('2')], '1')
expect(selectResource).not.toHaveBeenCalled()
expect(sendBrowserPanelAction).not.toHaveBeenCalled()
})

it('claims a native switch away from a page it was already showing', () => {
const resources: MothershipResource[] = [
{ type: 'browser', id: '1', title: 'Page 1' },
{ type: 'browser', id: '2', title: 'Page 2' },
]
const rerender = render()
pushTabs(SCOPE, [tab('1', true), tab('2')], '1')
rerender({ resources, activeResourceId: '1', selectedResourceId: null })
expect(selectResource).not.toHaveBeenCalled()

// A keyboard shortcut in the page moves the desktop app to page 2. With no
// explicit selection the strip resolves to that page in the same commit,
// so the switch is only visible against the page the desktop app left.
rerender({ resources, activeResourceId: '2' }, () => {
useBrowserSessionStore
.getState()
.setTabsState({ scopeId: SCOPE, tabs: [tab('1'), tab('2', true)], activeTabId: '2' })
})
expect(selectResource).toHaveBeenCalledExactlyOnceWith('2')
})

it('follows a native switch into the strip only while the user is on the browser', () => {
const resources: MothershipResource[] = [
{ type: 'browser', id: '1', title: 'Page 1' },
{ type: 'browser', id: '2', title: 'Page 2' },
{ type: 'file', id: 'f', title: 'notes.md' },
]
const rerender = render({ resources, activeResourceId: '1' })
const rerender = render({ resources, activeResourceId: '1', selectedResourceId: '1' })
pushTabs(SCOPE, [tab('1', true), tab('2')], '1')

pushTabs(SCOPE, [tab('1'), tab('2', true)], '2')
expect(selectResource).toHaveBeenCalledExactlyOnceWith('2')
expect(sendBrowserPanelAction).not.toHaveBeenCalled()

selectResource.mockClear()
rerender({ activeResourceId: 'f' })
rerender({ activeResourceId: 'f', selectedResourceId: 'f' })
pushTabs(SCOPE, [tab('1', true), tab('2')], '1')
expect(selectResource).not.toHaveBeenCalled()
})
Expand All @@ -192,6 +238,7 @@ describe('useBrowserTabResources', () => {
render({
resources: [{ type: 'browser', id: '1', title: 'Page 1' }],
activeResourceId: '1',
selectedResourceId: '1',
})
pushTabs(SCOPE, [tab('1', true)], '1')
act(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { getErrorMessage } from '@sim/utils/errors'
import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel'
import { browserTabTitle } from '@/lib/browser-agent/tab-label'
import { openUrlInNewBrowserTab, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import {
type DesktopTabResourceCallbacks,
type DesktopTabResourceOptions,
useDesktopTabResources,
} from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useBrowserSessionStore } from '@/stores/browser-session/store'
Expand All @@ -16,13 +15,6 @@ const logger = createLogger('BrowserTabResources')

const EMPTY_BROWSER_TABS: BrowserTabState[] = []

interface UseBrowserTabResourcesOptions extends DesktopTabResourceCallbacks {
/** Desktop browser scope whose pages back this chat's browser tabs. */
scopeId: string
resources: readonly MothershipResource[]
activeResourceId: string | null
}

function switchBrowserTab(tabId: string, scopeId: string): void {
sendBrowserPanelAction('switch-tab', { tabId, claim: false }, scopeId)
}
Expand All @@ -31,15 +23,8 @@ function switchBrowserTab(tabId: string, scopeId: string): void {
* Projects the desktop app's live browser pages into `browser` resource tabs,
* one per page. See {@link useDesktopTabResources} for the shared model.
*/
export function useBrowserTabResources({
scopeId,
resources,
activeResourceId,
addResource,
removeResource,
selectResource,
onResourceEvent,
}: UseBrowserTabResourcesOptions): void {
export function useBrowserTabResources(options: DesktopTabResourceOptions): void {
const { scopeId, selectResource } = options
const hasSession = useBrowserSessionStore((state) => state.sessions[scopeId] !== undefined)
const browserTabs = useBrowserSessionStore(
(state) => state.sessions[scopeId]?.tabs ?? EMPTY_BROWSER_TABS
Expand All @@ -64,19 +49,13 @@ export function useBrowserTabResources({
selectResourceRef.current = selectResource

useDesktopTabResources({
...options,
type: 'browser',
scopeId,
tabs,
hasSession,
activeTabId,
agentTabId,
switchTab: switchBrowserTab,
resources,
activeResourceId,
addResource,
removeResource,
selectResource,
onResourceEvent,
})

// Chat links clicked in the desktop app open in a new browser tab. The user
Expand Down
Loading
Loading