Skip to content

Commit 43d26eb

Browse files
committed
fix(desktop): mirror strip order into native tabs and drag live tab titles
Reordering a browser tab in the resource strip now reorders the desktop's native tab list too, so restore and the agent's tab list keep the strip's order (`reorderTab` returns as an optional bridge method). Dragging a tab into the chat carries the strip's live title rather than the title captured when the tab was added. The fork route test follows the new policy that stored browser rows are dropped.
1 parent bf40b95 commit 43d26eb

11 files changed

Lines changed: 130 additions & 7 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2403,6 +2403,19 @@ describe('browser-agent session', () => {
24032403
])
24042404
})
24052405

2406+
it('reorders tabs to the order the resource strip asks for', () => {
2407+
const first = session.ensureTab()
2408+
const second = session.addTab()
2409+
const third = session.addTab()
2410+
2411+
session.reorderTab(third.id, 0)
2412+
expect(session.listTabs().map((tab) => tab.tabId)).toEqual([third.id, first.id, second.id])
2413+
2414+
session.reorderTab(third.id, 99)
2415+
expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id])
2416+
expect(() => session.reorderTab('999', 0)).toThrow(/No tab with id 999/)
2417+
})
2418+
24062419
it('allows creation and reopening beyond eight browser tabs', () => {
24072420
session.ensureTab()
24082421
for (let index = 1; index < 12; index++) {

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,28 @@ export function switchAutomationTab(tabId: string): AgentTab {
31973197
return tab
31983198
}
31993199

3200+
/**
3201+
* Moves a tab to a final list index. The renderer's resource strip owns tab
3202+
* order; this keeps the native list — what restore and `browser_list_tabs`
3203+
* report — in the same order.
3204+
*/
3205+
export function reorderTab(tabId: string, targetIndex: number): AgentTab {
3206+
restoreBrowserSession()
3207+
const currentIndex = tabs.findIndex((entry) => entry.id === tabId)
3208+
if (currentIndex < 0) {
3209+
throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`)
3210+
}
3211+
const tab = tabs[currentIndex]
3212+
const nextIndex = Math.max(0, Math.min(tabs.length - 1, Math.trunc(targetIndex)))
3213+
if (nextIndex === currentIndex) return tab
3214+
3215+
tabs.splice(currentIndex, 1)
3216+
tabs.splice(nextIndex, 0, tab)
3217+
persistBrowserSession()
3218+
events?.onTabsChanged()
3219+
return tab
3220+
}
3221+
32003222
/**
32013223
* Closes a tab. When the agent closes its own working tab it moves on to the
32023224
* neighbour so its next page tool has a target; a close the user made leaves

‎apps/desktop/src/main/ipc.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,17 @@ describe('registerIpcHandlers', () => {
11461146
expect(() => handler?.(appEvent, { action: 'reload' })).not.toThrow()
11471147
})
11481148

1149+
it('restricts browser-tab reordering to typed app-origin messages', () => {
1150+
const { on } = collectHandlers()
1151+
const handler = on.get('browser-agent:reorder-tab')
1152+
1153+
expect(() => handler?.(evilEvent, '1', 0)).not.toThrow()
1154+
expect(() => handler?.(appEvent, 1, 0)).not.toThrow()
1155+
expect(() => handler?.(appEvent, '1', '0')).not.toThrow()
1156+
expect(() => handler?.(appEvent, '1', Number.NaN)).not.toThrow()
1157+
expect(() => handler?.(appEvent, '1', 0)).not.toThrow()
1158+
})
1159+
11491160
it('restricts browser-panel focus updates to boolean app-origin messages', () => {
11501161
const { on } = collectHandlers()
11511162
const handler = on.get('browser-agent:set-panel-focused')

‎apps/desktop/src/main/ipc.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
getBrowserDownloadsState,
5555
grantSiteOriginForUserNavigation,
5656
peekTabsState,
57+
reorderTab,
5758
setBrowserAppTheme,
5859
showBrowserDownloadInFolder,
5960
showBrowserDownloadsMenu,
@@ -1171,6 +1172,26 @@ export function registerIpcHandlers(deps: IpcDeps): void {
11711172
void handlePanelAction(scope, panelAction).catch(() => {})
11721173
},
11731174
},
1175+
'browser-agent:reorder-tab': {
1176+
kind: 'send',
1177+
gate: 'app-origin',
1178+
requires: 'browser',
1179+
passSender: true,
1180+
handler: (sender, tabId, targetIndex, rawScope) => {
1181+
const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope)
1182+
if (
1183+
!scope ||
1184+
typeof tabId !== 'string' ||
1185+
typeof targetIndex !== 'number' ||
1186+
!Number.isFinite(targetIndex)
1187+
) {
1188+
return
1189+
}
1190+
try {
1191+
withBrowserScope(scope, () => reorderTab(tabId, targetIndex))
1192+
} catch {}
1193+
},
1194+
},
11741195
'browser-agent:set-panel-bounds': {
11751196
kind: 'send',
11761197
gate: 'app-origin',

‎apps/desktop/src/preload/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ const api: SimDesktopApi = {
228228
ipcRenderer.invoke('browser-agent:dispose-scope', scopeId),
229229
suspendScope: (scopeId: string): Promise<boolean> =>
230230
ipcRenderer.invoke('browser-agent:suspend-scope', scopeId),
231+
reorderTab: (tabId: string, targetIndex: number, scopeId: string): void => {
232+
ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex, scopeId)
233+
},
231234
setPanelBounds: (
232235
bounds: BrowserPanelBounds | null,
233236
anchor: BrowserPanelAnchor | null,

‎apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
307307
expect(dbChainMockFns.values.mock.calls[0][0].title).toBe('Fork | Generate Logs')
308308
})
309309

310-
it('repairs legacy page-level browser resources while forking', async () => {
310+
it('drops legacy browser rows while forking, since the desktop app owns those pages', async () => {
311311
dbChainMockFns.limit.mockResolvedValue([
312312
{
313313
...parentRow,
@@ -318,6 +318,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
318318
title: 'mship-todo (Channel) - sim - Slack',
319319
},
320320
{ type: 'browser', id: 'browser-session', title: 'Browser' },
321+
{ type: 'file', id: 'file-1', title: 'report.csv' },
321322
],
322323
},
323324
])
@@ -326,7 +327,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
326327

327328
expect(res.status).toBe(200)
328329
expect(dbChainMockFns.values.mock.calls[0][0].resources).toEqual([
329-
{ type: 'browser', id: 'browser-session', title: 'Browser' },
330+
{ type: 'file', id: 'file-1', title: 'report.csv' },
330331
])
331332
})
332333

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
} from '@sim/emcn'
2323
import { Columns3, Eye, Pencil } from '@sim/emcn/icons'
2424
import { browserTabTitle } from '@/lib/browser-agent/tab-label'
25-
import { openBrowserTab, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
25+
import {
26+
openBrowserTab,
27+
reorderBrowserTab,
28+
sendBrowserPanelAction,
29+
} from '@/lib/browser-agent/transport'
2630
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
2731
import { isEphemeralResource } from '@/lib/copilot/resources/types'
2832
import { openTerminal } from '@/lib/terminal/transport'
@@ -396,11 +400,20 @@ export function ResourceTabs({
396400
[chatId, desktopScopeId, onRemoveResource, resources, selectedIds]
397401
)
398402

403+
/** The strip's own title for a resource — a browser tab's is its live page title. */
404+
const withStripTitle = useCallback(
405+
(resource: MothershipResource): MothershipResource => {
406+
const title = tabs.find((tab) => tab.id === resource.id)?.title
407+
return title && title !== resource.title ? { ...resource, title } : resource
408+
},
409+
[tabs]
410+
)
411+
399412
const handleTabDragStart = useCallback(
400413
(e: ReactDragEvent<HTMLDivElement>, id: string, drag: TabStripDragContext) => {
401414
const resource = resources.find((r) => r.id === id)
402415
if (!resource) return
403-
const selected = resources.filter((r) => selectedIds.has(r.id))
416+
const selected = resources.filter((r) => selectedIds.has(r.id)).map(withStripTitle)
404417
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
405418
if (isMultiDrag) {
406419
e.dataTransfer.effectAllowed = 'copy'
@@ -424,12 +437,13 @@ export function ResourceTabs({
424437
// and a drop target asking for `copy` is refused outright unless copying
425438
// is allowed too.
426439
e.dataTransfer.effectAllowed = 'copyMove'
440+
const { type, id: resourceId, title } = withStripTitle(resource)
427441
e.dataTransfer.setData(
428442
SIM_RESOURCE_DRAG_TYPE,
429-
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
443+
JSON.stringify({ type, id: resourceId, title })
430444
)
431445
},
432-
[resources, selectedIds]
446+
[resources, selectedIds, withStripTitle]
433447
)
434448

435449
const handleReorder = useCallback(
@@ -440,6 +454,12 @@ export function ResourceTabs({
440454
const [moved] = reordered.splice(fromIndex, 1)
441455
reordered.splice(targetIndex, 0, moved)
442456
onReorderResources(reordered)
457+
// Browser tabs are not stored with the chat; their order lives in the
458+
// desktop's native list, which restore and the agent read back.
459+
if (moved.type === 'browser') {
460+
const browserIndex = reordered.filter((r) => r.type === 'browser').indexOf(moved)
461+
reorderBrowserTab(moved.id, browserIndex, desktopScopeId)
462+
}
443463
if (chatId) {
444464
const persistable = reordered.filter((r) => !isEphemeralResource(r))
445465
if (persistable.length > 0) {
@@ -448,7 +468,7 @@ export function ResourceTabs({
448468
}
449469
},
450470
// eslint-disable-next-line react-hooks/exhaustive-deps
451-
[chatId, resources, onReorderResources]
471+
[chatId, desktopScopeId, resources, onReorderResources]
452472
)
453473

454474
const previewToggle =

‎apps/sim/lib/browser-agent/transport.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const {
2929
openUrlAvailable,
3030
panelAction,
3131
registerSitePermissionPromptSupport,
32+
reorderTab,
3233
restoreScope,
3334
nativeSuspendScope,
3435
setPageState,
@@ -69,6 +70,7 @@ const {
6970
openUrlAvailable: { current: true },
7071
panelAction: vi.fn(),
7172
registerSitePermissionPromptSupport: vi.fn(),
73+
reorderTab: vi.fn(),
7274
restoreScope: vi.fn(async (scopeId: string) => ({ scopeId, tabs: [], activeTabId: null })),
7375
nativeSuspendScope: vi.fn(async () => true),
7476
setPageState: vi.fn(),
@@ -108,6 +110,7 @@ vi.mock('@/lib/desktop', () => ({
108110
openUrl: openUrlAvailable.current ? openUrl : undefined,
109111
panelAction,
110112
registerSitePermissionPromptSupport,
113+
reorderTab,
111114
restoreScope,
112115
suspendScope: nativeSuspendScope,
113116
setPanelBounds,
@@ -160,6 +163,7 @@ import {
160163
onBrowserToolbarCommand,
161164
openBrowserTab,
162165
openUrlInNewBrowserTab,
166+
reorderBrowserTab,
163167
reportBrowserPanelBounds,
164168
reportBrowserPanelFocused,
165169
reportBrowserTheme,
@@ -337,6 +341,12 @@ describe('browser panel transport', () => {
337341
expect(supportsAtomicBrowserPanelOcclusion()).toBe(true)
338342
})
339343

344+
it('mirrors a strip reorder into the native tab list', () => {
345+
reorderBrowserTab('tab-3', 1)
346+
347+
expect(reorderTab).toHaveBeenCalledWith('tab-3', 1, 'chat-test')
348+
})
349+
340350
it('opens and scopes the native browser toolbar menu', () => {
341351
showBrowserToolbarMenu({ x: 10, y: 20 }, 'chat-a')
342352
expect(showToolbarMenu).toHaveBeenCalledWith({ x: 10, y: 20 }, 'chat-a')

‎apps/sim/lib/browser-agent/transport.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,18 @@ export async function openUrlInNewBrowserTab(
383383
return state?.activeTabId ?? null
384384
}
385385

386+
/**
387+
* Mirrors a resource-strip reorder into the native tab list, so restore and
388+
* the agent's tab list keep the strip's order. Older shells keep native order.
389+
*/
390+
export function reorderBrowserTab(
391+
tabId: string,
392+
targetIndex: number,
393+
scopeId = currentBrowserScopeId()
394+
): void {
395+
bridge()?.reorderTab?.(tabId, targetIndex, scopeId)
396+
}
397+
386398
/** Mirrors Sim's raw light/dark/system preference into embedded pages. */
387399
export function reportBrowserTheme(theme: BrowserTheme): void {
388400
bridge()?.setTheme(theme)

‎packages/desktop-bridge/contract-snapshot.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,11 @@ export interface SimDesktopBrowserAgentApi {
10851085
disposeScope(scopeId: string): Promise<boolean>
10861086
/** Closes a soft-deleted chat's live pages while retaining its restart descriptor. */
10871087
suspendScope(scopeId: string): Promise<boolean>
1088+
/**
1089+
* Move a live tab to a final list index, mirroring the resource strip.
1090+
* Optional for compatibility with installed shells that predate strip-owned order.
1091+
*/
1092+
reorderTab?(tabId: string, targetIndex: number, scopeId: string): void
10881093
/**
10891094
* Report where the browser panel sits in the window (CSS pixels relative
10901095
* to the viewport), or null when the panel is hidden/unmounted. The main

0 commit comments

Comments
 (0)