Skip to content

Commit 98e796a

Browse files
committed
feat(desktop): add browser tab duplication actions
1 parent ffe603a commit 98e796a

7 files changed

Lines changed: 71 additions & 13 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,12 @@ export async function handlePanelAction(action: BrowserPanelAction): Promise<voi
807807
session.addTab()
808808
return
809809
}
810+
if (action.action === 'duplicate-tab') {
811+
if (typeof action.tabId === 'string') {
812+
session.duplicateTab(action.tabId)
813+
}
814+
return
815+
}
810816
if (action.action === 'switch-tab') {
811817
if (typeof action.tabId === 'string') {
812818
session.switchTab(action.tabId)

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,27 @@ export function reopenClosedTab(): AgentTab | null {
524524
return tab
525525
}
526526

527+
/**
528+
* Opens a copy of a tab at the same URL. A duplicate is a fresh load rather
529+
* than a clone of the original's session history: the history belongs to the
530+
* WebContents, and there is no way to fork it.
531+
*/
532+
export function duplicateTab(tabId: string): AgentTab | null {
533+
restorePinnedTabs()
534+
const source = tabs.find((entry) => entry.id === tabId)
535+
if (!source || listTabs().length >= MAX_BROWSER_TABS) return null
536+
537+
const url = sanitizeRestorableUrl(source.view.webContents.getURL())
538+
const tab = addTabInternal()
539+
if (url && url !== 'about:blank') {
540+
// Sanitized to http(s) without embedded credentials above, and the
541+
// partition's onBeforeRequest still runs the full SSRF check on the load —
542+
// same reasoning as reopenClosedTab, and this is likewise a user action.
543+
void tab.view.webContents.loadURL(url).catch(() => {})
544+
}
545+
return tab
546+
}
547+
527548
export function switchTab(tabId: string): AgentTab {
528549
restorePinnedTabs()
529550
const tab = tabs.find((entry) => entry.id === tabId)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,10 @@ export function BrowserSession() {
271271
sendBrowserPanelAction('close-tab', { tabId })
272272
}, [])
273273

274+
const handleDuplicateTab = useCallback((tabId: string) => {
275+
sendBrowserPanelAction('duplicate-tab', { tabId })
276+
}, [])
277+
274278
const handleSetTabPinned = useCallback((tabId: string, pinned: boolean) => {
275279
setBrowserTabPinned(tabId, pinned)
276280
}, [])
@@ -289,6 +293,7 @@ export function BrowserSession() {
289293
onNewTab={handleNewTab}
290294
onSwitchTab={handleSwitchTab}
291295
onCloseTab={handleCloseTab}
296+
onDuplicateTab={handleDuplicateTab}
292297
onSetTabPinned={handleSetTabPinned}
293298
onReorderTab={handleReorderTab}
294299
pinningSupported={tabPinningSupported}

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface BrowserTabStripProps {
1414
onNewTab: () => void
1515
onSwitchTab: (tabId: string) => void
1616
onCloseTab: (tabId: string) => void
17+
onDuplicateTab: (tabId: string) => void
1718
onSetTabPinned: (tabId: string, pinned: boolean) => void
1819
onReorderTab: (tabId: string, targetIndex: number) => void
1920
pinningSupported: boolean
@@ -58,7 +59,8 @@ function BrowserTabIcon({ tab }: { tab: BrowserTabState }) {
5859

5960
/**
6061
* The browser panel's tab strip: the shared {@link TabStrip} plus the two
61-
* things only the browser has — favicons, and a pin/unpin context menu. The
62+
* things only the browser has — favicons, and a right-click menu carrying
63+
* pin/unpin alongside the duplicate and close every tab strip offers. The
6264
* active Electron view remains the only native view attached over the panel;
6365
* selecting a tab switches which live view is attached.
6466
*/
@@ -68,6 +70,7 @@ export function BrowserTabStrip({
6870
onNewTab,
6971
onSwitchTab,
7072
onCloseTab,
73+
onDuplicateTab,
7174
onSetTabPinned,
7275
onReorderTab,
7376
pinningSupported,
@@ -98,15 +101,10 @@ export function BrowserTabStrip({
98101
const openTabContextMenu = useCallback(
99102
(event: ReactMouseEvent<HTMLDivElement>, tabId: string) => {
100103
window.getSelection()?.removeAllRanges()
101-
if (!pinningSupported) {
102-
event.preventDefault()
103-
event.stopPropagation()
104-
return
105-
}
106104
setContextTabId(tabId)
107105
handleContextMenu(event)
108106
},
109-
[handleContextMenu, pinningSupported]
107+
[handleContextMenu]
110108
)
111109

112110
return (
@@ -120,7 +118,7 @@ export function BrowserTabStrip({
120118
{...(reorderingSupported ? { onReorder: onReorderTab } : {})}
121119
>
122120
<ContextMenu
123-
isOpen={isContextMenuOpen && Boolean(contextTab) && pinningSupported}
121+
isOpen={isContextMenuOpen && Boolean(contextTab)}
124122
position={contextMenuPosition}
125123
menuRef={contextMenuRef}
126124
onClose={closeContextMenu}
@@ -129,11 +127,17 @@ export function BrowserTabStrip({
129127
? () => onSetTabPinned(contextTab.tabId, !contextTab.pinned)
130128
: undefined
131129
}
130+
onDuplicate={contextTab ? () => onDuplicateTab(contextTab.tabId) : undefined}
131+
// A pinned tab has no close affordance in the strip either.
132+
{...(contextTab && !contextTab.pinned
133+
? { onCloseTab: () => onCloseTab(contextTab.tabId), showCloseTab: true }
134+
: {})}
132135
onDelete={() => {}}
133136
showPin={Boolean(contextTab) && pinningSupported}
134137
isPinned={Boolean(contextTab?.pinned)}
135138
showRename={false}
136-
showDuplicate={false}
139+
showDuplicate={Boolean(contextTab)}
140+
disableDuplicate={tabs.length >= MAX_BROWSER_TABS}
137141
showDelete={false}
138142
/>
139143
</TabStrip>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SquareArrowUpRight,
2323
Trash,
2424
Unlock,
25+
X,
2526
} from '@sim/emcn/icons'
2627
import { Pin, PinOff } from 'lucide-react'
2728

@@ -52,6 +53,12 @@ interface ContextMenuProps {
5253
onDuplicate?: () => void
5354
onExport?: () => void
5455
onDelete: () => void
56+
/**
57+
* Closes the item rather than deleting it — for tabs, where the destructive
58+
* action is "close this one", not "delete it forever". Named for the item so
59+
* it cannot be confused with `onClose`, which dismisses this menu.
60+
*/
61+
onCloseTab?: () => void
5562
showOpenInNewTab?: boolean
5663
showMarkAsRead?: boolean
5764
showMarkAsUnread?: boolean
@@ -78,6 +85,7 @@ interface ContextMenuProps {
7885
disableLock?: boolean
7986
isLocked?: boolean
8087
showDelete?: boolean
88+
showCloseTab?: boolean
8189
onUploadLogo?: () => void
8290
showUploadLogo?: boolean
8391
disableUploadLogo?: boolean
@@ -103,6 +111,7 @@ export function ContextMenu({
103111
onDuplicate,
104112
onExport,
105113
onDelete,
114+
onCloseTab,
106115
showOpenInNewTab = false,
107116
showMarkAsRead = false,
108117
showMarkAsUnread = false,
@@ -129,6 +138,7 @@ export function ContextMenu({
129138
disableLock = false,
130139
isLocked = false,
131140
showDelete = true,
141+
showCloseTab = false,
132142
onUploadLogo,
133143
showUploadLogo = false,
134144
disableUploadLogo = false,
@@ -325,7 +335,7 @@ export function ContextMenu({
325335
)}
326336

327337
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
328-
(showLeave || showDelete) && <DropdownMenuSeparator />}
338+
(showLeave || showDelete || (showCloseTab && onCloseTab)) && <DropdownMenuSeparator />}
329339
{showLeave && onLeave && (
330340
<DropdownMenuItem
331341
disabled={disableLeave}
@@ -350,6 +360,17 @@ export function ContextMenu({
350360
Delete
351361
</DropdownMenuItem>
352362
)}
363+
{showCloseTab && onCloseTab && (
364+
<DropdownMenuItem
365+
onSelect={() => {
366+
onCloseTab()
367+
onClose()
368+
}}
369+
>
370+
<X />
371+
Close
372+
</DropdownMenuItem>
373+
)}
353374
</DropdownMenuContent>
354375
</DropdownMenu>
355376
)

apps/sim/lib/terminal/transport.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ export function resizeTerminal(terminalId: string, cols: number, rows: number):
8383
bridge()?.resize(terminalId, cols, rows)
8484
}
8585

86-
export async function openTerminal(): Promise<void> {
87-
await bridge()?.openTerminal()
86+
export async function openTerminal(cwd?: string): Promise<void> {
87+
await bridge()?.openTerminal(cwd)
8888
}
8989

9090
export async function switchTerminal(terminalId: string): Promise<void> {

packages/browser-protocol/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,13 @@ export interface BrowserPanelAction {
135135
| 'back'
136136
| 'forward'
137137
| 'new-tab'
138+
| 'duplicate-tab'
138139
| 'switch-tab'
139140
| 'close-tab'
140141
| 'takeover-done'
141142
/** Absolute URL for `navigate` (typed into the panel's URL bar). */
142143
url?: string
143-
/** Stable tab id for `switch-tab` and `close-tab`. */
144+
/** Stable tab id for `duplicate-tab`, `switch-tab`, and `close-tab`. */
144145
tabId?: string
145146
}
146147

0 commit comments

Comments
 (0)