Add in-app browser tabs (#430)

This commit is contained in:
Jinwoo Hong
2026-04-10 00:04:42 -07:00
committed by GitHub
parent fee1165747
commit c6ff6da3a8
45 changed files with 3953 additions and 345 deletions
+185
View File
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
shellOpenExternalMock,
menuBuildFromTemplateMock,
guestOffMock,
guestOnMock,
guestSetBackgroundThrottlingMock,
guestSetWindowOpenHandlerMock,
guestOpenDevToolsMock,
webContentsFromIdMock
} = vi.hoisted(() => ({
shellOpenExternalMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn()
}))
vi.mock('electron', () => ({
clipboard: { writeText: vi.fn() },
shell: { openExternal: shellOpenExternalMock },
Menu: {
buildFromTemplate: menuBuildFromTemplateMock
},
webContents: {
fromId: webContentsFromIdMock
}
}))
import { browserManager } from './browser-manager'
describe('browserManager', () => {
beforeEach(() => {
shellOpenExternalMock.mockReset()
menuBuildFromTemplateMock.mockReset()
guestOffMock.mockReset()
guestOnMock.mockReset()
guestSetBackgroundThrottlingMock.mockReset()
guestSetWindowOpenHandlerMock.mockReset()
guestOpenDevToolsMock.mockReset()
webContentsFromIdMock.mockReset()
browserManager.unregisterAll()
})
it('validates popup URLs before opening externally', () => {
const guest = {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: {
url: string
}) => { action: 'deny' }
expect(handler({ url: 'localhost:3000' })).toEqual({ action: 'deny' })
expect(handler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(shellOpenExternalMock).toHaveBeenCalledTimes(1)
expect(shellOpenExternalMock).toHaveBeenCalledWith('http://localhost:3000/')
})
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const willNavigateHandler = guestOnMock.mock.calls.find(
([event]) => event === 'will-navigate'
)?.[1] as ((event: { preventDefault: () => void }, url: string) => void) | undefined
expect(willNavigateHandler).toBeTypeOf('function')
const preventDefault = vi.fn()
willNavigateHandler?.({ preventDefault }, 'file:///etc/passwd')
expect(preventDefault).toHaveBeenCalledTimes(1)
})
it('unregisterAll clears tracked guests and context-menu listeners', () => {
const guest = {
id: 101,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({ browserTabId: 'browser-1', webContentsId: 101 })
browserManager.attachGuestPolicies({ ...guest, id: 102 } as never)
browserManager.registerGuest({ browserTabId: 'browser-2', webContentsId: 102 })
browserManager.unregisterAll()
expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull()
expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull()
expect(guestOffMock).toHaveBeenCalled()
})
it('rejects non-webview guest types to prevent privilege escalation', () => {
// A compromised renderer could send the main window's own webContentsId.
// registerGuest must reject it because getType() would return 'window',
// not 'webview'.
const mainWindowContents = {
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'window'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(mainWindowContents)
browserManager.registerGuest({ browserTabId: 'browser-evil', webContentsId: 1 })
// The guest should NOT be registered
expect(browserManager.getGuestWebContentsId('browser-evil')).toBeNull()
// setWindowOpenHandler must NOT have been called on the main window's webContents
expect(guestSetWindowOpenHandlerMock).not.toHaveBeenCalled()
})
it('rejects registration for guests that never received attach-time policy wiring', () => {
const guest = {
id: 777,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.registerGuest({ browserTabId: 'browser-1', webContentsId: 777 })
expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull()
expect(menuBuildFromTemplateMock).not.toHaveBeenCalled()
})
it('does not duplicate guest policy listeners when attach is reported twice', () => {
const guest = {
id: 303,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
browserManager.attachGuestPolicies(guest as never)
browserManager.attachGuestPolicies(guest as never)
expect(guestSetBackgroundThrottlingMock).toHaveBeenCalledTimes(1)
expect(guestSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1)
expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1)
expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1)
})
})
+283
View File
@@ -0,0 +1,283 @@
import { clipboard, Menu, shell, webContents } from 'electron'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
} from '../../shared/browser-url'
export type BrowserGuestRegistration = {
browserTabId: string
webContentsId: number
rendererWebContentsId: number
}
class BrowserManager {
private readonly webContentsIdByTabId = new Map<string, number>()
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly pendingLoadFailuresByGuestId = new Map<
number,
{ code: number; description: string; validatedUrl: string }
>()
private openValidatedExternal(rawUrl: string): void {
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (externalUrl) {
void shell.openExternal(externalUrl)
}
}
attachGuestPolicies(guest: Electron.WebContents): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
guest.setBackgroundThrottling(true)
guest.setWindowOpenHandler(({ url }) => {
// Why: popup-capable guests are required for OAuth and target=_blank
// flows, but Orca still does not host child windows itself. Convert those
// attempts into a controlled external-open path instead of letting them
// silently fail or spawn unmanaged windows.
this.openValidatedExternal(url)
return { action: 'deny' }
})
const navigationGuard = (event: Electron.Event, url: string): void => {
if (!normalizeBrowserNavigationUrl(url)) {
// Why: `will-attach-webview` only validates the initial src. Main must
// keep enforcing the same allowlist for later guest navigations too.
event.preventDefault()
}
}
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', navigationGuard)
guest.on(
'did-fail-load',
(
_event: Electron.Event,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean
) => {
if (!isMainFrame || errorCode === -3) {
return
}
this.forwardOrQueueGuestLoadFailure(guest.id, {
code: errorCode,
description: errorDescription || 'This site could not be reached.',
validatedUrl: validatedURL || guest.getURL() || 'about:blank'
})
}
)
}
registerGuest({
browserTabId,
webContentsId,
rendererWebContentsId
}: BrowserGuestRegistration): void {
const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId)
if (previousCleanup) {
previousCleanup()
this.contextMenuCleanupByTabId.delete(browserTabId)
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
}
// Why: the renderer sends webContentsId, which we must not blindly trust.
// A compromised renderer could send the main window's own webContentsId,
// causing us to overwrite its setWindowOpenHandler or attach unintended
// context menus. Only accept genuine webview guest surfaces.
if (guest.getType() !== 'webview') {
return
}
if (!this.policyAttachedGuestIds.has(webContentsId)) {
// Why: renderer registration is only the second half of the guest setup.
// Main must only trust guests that already passed attach-time policy
// installation; otherwise a trusted renderer could point us at some other
// arbitrary webview and bypass the intended host-window attach boundary.
return
}
this.webContentsIdByTabId.set(browserTabId, webContentsId)
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
this.setupContextMenu(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
}
unregisterGuest(browserTabId: string): void {
const cleanup = this.contextMenuCleanupByTabId.get(browserTabId)
if (cleanup) {
cleanup()
this.contextMenuCleanupByTabId.delete(browserTabId)
}
this.webContentsIdByTabId.delete(browserTabId)
this.rendererWebContentsIdByTabId.delete(browserTabId)
}
unregisterAll(): void {
for (const browserTabId of this.webContentsIdByTabId.keys()) {
this.unregisterGuest(browserTabId)
}
this.policyAttachedGuestIds.clear()
this.pendingLoadFailuresByGuestId.clear()
}
getGuestWebContentsId(browserTabId: string): number | null {
return this.webContentsIdByTabId.get(browserTabId) ?? null
}
// Why: guest browser surfaces are intentionally isolated from Orca's preload
// bridge, so renderer code cannot directly call Electron WebContents APIs on
// them. Main owns the devtools escape hatch and only after tab→guest lookup.
async openDevTools(browserTabId: string): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
this.webContentsIdByTabId.delete(browserTabId)
return false
}
guest.openDevTools({ mode: 'detach' })
return true
}
private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
const handler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
const pageUrl = guest.getURL()
const linkUrl = params.linkURL || ''
const template: Electron.MenuItemConstructorOptions[] = []
if (linkUrl) {
const externalLinkUrl = normalizeExternalBrowserUrl(linkUrl)
template.push(
{
label: 'Open Link In Default Browser',
enabled: Boolean(externalLinkUrl && externalLinkUrl !== 'about:blank'),
click: () => {
this.openValidatedExternal(linkUrl)
}
},
{
label: 'Copy Link Address',
click: () => {
clipboard.writeText(linkUrl)
}
},
{ type: 'separator' }
)
}
const externalPageUrl = normalizeExternalBrowserUrl(pageUrl)
template.push(
{
label: 'Back',
enabled: guest.canGoBack(),
click: () => guest.goBack()
},
{
label: 'Forward',
enabled: guest.canGoForward(),
click: () => guest.goForward()
},
{
label: 'Reload',
click: () => guest.reload()
},
{ type: 'separator' },
{
label: 'Open Page In Default Browser',
enabled: Boolean(externalPageUrl && externalPageUrl !== 'about:blank'),
click: () => {
this.openValidatedExternal(pageUrl)
}
},
{
label: 'Copy Page URL',
enabled: Boolean(pageUrl),
click: () => {
clipboard.writeText(pageUrl)
}
},
{ type: 'separator' },
{
label: 'Inspect Page',
click: () => {
void this.openDevTools(browserTabId)
}
}
)
Menu.buildFromTemplate(template).popup()
}
guest.on('context-menu', handler)
this.contextMenuCleanupByTabId.set(browserTabId, () => {
try {
guest.off('context-menu', handler)
} catch {
// Why: browser tabs can outlive the guest webContents briefly during
// teardown. Cleanup should be best-effort instead of throwing while the
// IDE is closing a tab.
}
})
}
private forwardOrQueueGuestLoadFailure(
guestWebContentsId: number,
loadError: { code: number; description: string; validatedUrl: string }
): void {
const browserTabId = [...this.webContentsIdByTabId.entries()].find(
([, webContentsId]) => webContentsId === guestWebContentsId
)?.[0]
if (!browserTabId) {
// Why: some localhost failures happen before the renderer finishes
// registering which tab owns this guest. Queue the failure by guest ID so
// registerGuest can replay it instead of silently losing the error state.
this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError)
return
}
this.sendGuestLoadFailure(browserTabId, loadError)
}
private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void {
const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId)
if (!pending) {
return
}
this.pendingLoadFailuresByGuestId.delete(guestWebContentsId)
this.sendGuestLoadFailure(browserTabId, pending)
}
private sendGuestLoadFailure(
browserTabId: string,
loadError: { code: number; description: string; validatedUrl: string }
): void {
const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId)
if (!rendererWebContentsId) {
return
}
const renderer = webContents.fromId(rendererWebContentsId)
if (!renderer || renderer.isDestroyed()) {
return
}
renderer.send('browser:guest-load-failed', {
browserTabId,
loadError
})
}
}
export const browserManager = new BrowserManager()
+7 -1
View File
@@ -51,8 +51,15 @@ function openMainWindow(): BrowserWindow {
if (!runtime) {
throw new Error('Runtime must be initialized before opening the main window')
}
if (!stats) {
throw new Error('Stats must be initialized before opening the main window')
}
if (!claudeUsage) {
throw new Error('Claude usage store must be initialized before opening the main window')
}
const window = createMainWindow(store)
registerCoreHandlers(store, runtime, stats, claudeUsage, window.webContents.id)
attachMainWindowServices(window, store, runtime)
window.on('closed', () => {
if (mainWindow === window) {
@@ -97,7 +104,6 @@ app.whenReady().then(async () => {
mainWindow?.webContents.send('terminal:zoom', 'reset')
}
})
registerCoreHandlers(store, runtime, stats, claudeUsage)
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData')
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { removeHandlerMock, handleMock, registerGuestMock, unregisterGuestMock, openDevToolsMock } =
vi.hoisted(() => ({
removeHandlerMock: vi.fn(),
handleMock: vi.fn(),
registerGuestMock: vi.fn(),
unregisterGuestMock: vi.fn(),
openDevToolsMock: vi.fn().mockResolvedValue(true)
}))
vi.mock('electron', () => ({
ipcMain: {
removeHandler: removeHandlerMock,
handle: handleMock
}
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
registerGuest: registerGuestMock,
unregisterGuest: unregisterGuestMock,
openDevTools: openDevToolsMock
}
}))
import { registerBrowserHandlers } from './browser'
describe('registerBrowserHandlers', () => {
beforeEach(() => {
removeHandlerMock.mockReset()
handleMock.mockReset()
registerGuestMock.mockReset()
unregisterGuestMock.mockReset()
openDevToolsMock.mockReset()
openDevToolsMock.mockResolvedValue(true)
})
it('rejects non-window callers', async () => {
registerBrowserHandlers()
const registerHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:registerGuest'
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean
const result = registerHandler(
{
sender: {
isDestroyed: () => false,
getType: () => 'webview',
getURL: () => 'http://localhost:5173/'
} as Electron.WebContents
},
{ browserTabId: 'browser-1', webContentsId: 101 }
)
expect(result).toBe(false)
expect(registerGuestMock).not.toHaveBeenCalled()
})
})
+63
View File
@@ -0,0 +1,63 @@
import { ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
let trustedBrowserRendererWebContentsId: number | null = null
export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void {
trustedBrowserRendererWebContentsId = webContentsId
}
function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean {
if (sender.isDestroyed() || sender.getType() !== 'window') {
return false
}
if (trustedBrowserRendererWebContentsId != null) {
return sender.id === trustedBrowserRendererWebContentsId
}
const senderUrl = sender.getURL()
if (process.env.ELECTRON_RENDERER_URL) {
try {
return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin
} catch {
return false
}
}
return senderUrl.startsWith('file://')
}
export function registerBrowserHandlers(): void {
ipcMain.removeHandler('browser:registerGuest')
ipcMain.removeHandler('browser:unregisterGuest')
ipcMain.removeHandler('browser:openDevTools')
ipcMain.handle(
'browser:registerGuest',
(event, args: { browserTabId: string; webContentsId: number }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
browserManager.registerGuest({
...args,
rendererWebContentsId: event.sender.id
})
return true
}
)
ipcMain.handle('browser:unregisterGuest', (event, args: { browserTabId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
browserManager.unregisterGuest(args.browserTabId)
return true
})
ipcMain.handle('browser:openDevTools', (event, args: { browserTabId: string }) => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserManager.openDevTools(args.browserTabId)
})
}
+15 -2
View File
@@ -14,7 +14,9 @@ const {
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
registerClipboardHandlersMock,
registerUpdaterHandlersMock
registerUpdaterHandlersMock,
registerBrowserHandlersMock,
setTrustedBrowserRendererWebContentsIdMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@@ -29,7 +31,9 @@ const {
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn()
registerUpdaterHandlersMock: vi.fn(),
registerBrowserHandlersMock: vi.fn(),
setTrustedBrowserRendererWebContentsIdMock: vi.fn()
}))
vi.mock('./cli', () => ({
@@ -85,6 +89,11 @@ vi.mock('../window/attach-main-window-services', () => ({
registerUpdaterHandlers: registerUpdaterHandlersMock
}))
vi.mock('./browser', () => ({
registerBrowserHandlers: registerBrowserHandlersMock,
setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock
}))
import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
@@ -103,6 +112,8 @@ describe('registerCoreHandlers', () => {
registerRuntimeHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
registerUpdaterHandlersMock.mockReset()
registerBrowserHandlersMock.mockReset()
setTrustedBrowserRendererWebContentsIdMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
@@ -127,5 +138,7 @@ describe('registerCoreHandlers', () => {
expect(registerShellHandlersMock).toHaveBeenCalled()
expect(registerClipboardHandlersMock).toHaveBeenCalled()
expect(registerUpdaterHandlersMock).toHaveBeenCalled()
expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null)
expect(registerBrowserHandlersMock).toHaveBeenCalled()
})
})
+6 -1
View File
@@ -9,8 +9,10 @@ import { registerGitHubHandlers } from './github'
import { registerStatsHandlers } from './stats'
import { registerRuntimeHandlers } from './runtime'
import { registerNotificationHandlers } from './notifications'
import { setTrustedBrowserRendererWebContentsId } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerBrowserHandlers } from './browser'
import { registerShellHandlers } from './shell'
import { registerUIHandlers } from './ui'
import { warmSystemFontFamilies } from '../system-fonts'
@@ -24,8 +26,10 @@ export function registerCoreHandlers(
store: Store,
runtime: OrcaRuntimeService,
stats: StatsCollector,
claudeUsage: ClaudeUsageStore
claudeUsage: ClaudeUsageStore,
mainWindowWebContentsId: number | null = null
): void {
setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId)
registerCliHandlers()
registerPreflightHandlers()
registerClaudeUsageHandlers(claudeUsage)
@@ -33,6 +37,7 @@ export function registerCoreHandlers(
registerStatsHandlers(stats)
registerNotificationHandlers(store)
registerSettingsHandlers(store)
registerBrowserHandlers()
registerShellHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
@@ -4,23 +4,34 @@ const {
onMock,
removeAllListenersMock,
setPermissionRequestHandlerMock,
setPermissionCheckHandlerMock,
setDisplayMediaRequestHandlerMock,
registerRepoHandlersMock,
registerWorktreeHandlersMock,
registerPtyHandlersMock,
setupAutoUpdaterMock
setupAutoUpdaterMock,
sessionFromPartitionMock,
browserManagerUnregisterAllMock
} = vi.hoisted(() => ({
onMock: vi.fn(),
removeAllListenersMock: vi.fn(),
setPermissionRequestHandlerMock: vi.fn(),
setPermissionCheckHandlerMock: vi.fn(),
setDisplayMediaRequestHandlerMock: vi.fn(),
registerRepoHandlersMock: vi.fn(),
registerWorktreeHandlersMock: vi.fn(),
registerPtyHandlersMock: vi.fn(),
setupAutoUpdaterMock: vi.fn()
setupAutoUpdaterMock: vi.fn(),
sessionFromPartitionMock: vi.fn(),
browserManagerUnregisterAllMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {},
clipboard: {},
session: {
fromPartition: sessionFromPartitionMock
},
ipcMain: {
on: onMock,
removeAllListeners: removeAllListenersMock,
@@ -41,6 +52,12 @@ vi.mock('../ipc/pty', () => ({
registerPtyHandlers: registerPtyHandlersMock
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
unregisterAll: browserManagerUnregisterAllMock
}
}))
vi.mock('../updater', () => ({
checkForUpdates: vi.fn(),
getUpdateStatus: vi.fn(),
@@ -55,10 +72,20 @@ describe('attachMainWindowServices', () => {
onMock.mockReset()
removeAllListenersMock.mockReset()
setPermissionRequestHandlerMock.mockReset()
setPermissionCheckHandlerMock.mockReset()
setDisplayMediaRequestHandlerMock.mockReset()
registerRepoHandlersMock.mockReset()
registerWorktreeHandlersMock.mockReset()
registerPtyHandlersMock.mockReset()
setupAutoUpdaterMock.mockReset()
sessionFromPartitionMock.mockReset()
browserManagerUnregisterAllMock.mockReset()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: vi.fn()
})
})
it('only allows the explicit permission allowlist', () => {
@@ -81,7 +108,7 @@ describe('attachMainWindowServices', () => {
attachMainWindowServices(mainWindow as never, store as never, runtime as never)
expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(1)
expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(2)
const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0]
const callback = vi.fn()
@@ -93,6 +120,104 @@ describe('attachMainWindowServices', () => {
expect(callback.mock.calls).toEqual([[true], [true], [true], [false]])
})
it('denies browser-session permissions, display capture, and downloads by default', () => {
const browserSessionOnMock = vi.fn()
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: browserSessionOnMock
})
const mainWindowOnMock = vi.fn()
const mainWindow = {
on: mainWindowOnMock,
webContents: {
on: vi.fn(),
session: {
setPermissionRequestHandler: setPermissionRequestHandlerMock
}
}
}
const store = { flush: vi.fn() }
const runtime = {
attachWindow: vi.fn(),
setNotifier: vi.fn(),
markRendererReloading: vi.fn(),
markGraphUnavailable: vi.fn()
}
attachMainWindowServices(mainWindow as never, store as never, runtime as never)
const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as (
wc: unknown,
permission: string,
callback: (allowed: boolean) => void
) => void
const permissionCallback = vi.fn()
browserPermissionHandler(null, 'fullscreen', permissionCallback)
browserPermissionHandler(null, 'media', permissionCallback)
expect(permissionCallback.mock.calls).toEqual([[true], [false]])
const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[0][0] as (
wc: unknown,
permission: string
) => boolean
expect(browserPermissionCheckHandler(null, 'fullscreen')).toBe(true)
expect(browserPermissionCheckHandler(null, 'notifications')).toBe(false)
const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0] as (
request: unknown,
callback: (streams: { video: null; audio: null }) => void
) => void
const displayCallback = vi.fn()
displayMediaHandler(null, displayCallback)
expect(displayCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined })
const willDownloadHandler = browserSessionOnMock.mock.calls.find(
([eventName]) => eventName === 'will-download'
)?.[1] as (event: { preventDefault: () => void }) => void
const preventDefault = vi.fn()
willDownloadHandler({ preventDefault })
expect(preventDefault).toHaveBeenCalledTimes(1)
})
it('clears browser guest registrations when the main window closes', () => {
sessionFromPartitionMock.mockReturnValue({
setPermissionRequestHandler: setPermissionRequestHandlerMock,
setPermissionCheckHandler: setPermissionCheckHandlerMock,
setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock,
on: vi.fn()
})
const mainWindowOnMock = vi.fn()
const mainWindow = {
on: mainWindowOnMock,
webContents: {
on: vi.fn(),
session: {
setPermissionRequestHandler: setPermissionRequestHandlerMock
}
}
}
const store = { flush: vi.fn() }
const runtime = {
attachWindow: vi.fn(),
setNotifier: vi.fn(),
markRendererReloading: vi.fn(),
markGraphUnavailable: vi.fn()
}
attachMainWindowServices(mainWindow as never, store as never, runtime as never)
const closedHandler = mainWindowOnMock.mock.calls
.filter(([event]) => event === 'closed')
.at(-1)?.[1] as (() => void) | undefined
expect(closedHandler).toBeTypeOf('function')
closedHandler?.()
expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1)
})
it('forwards runtime notifier events to the renderer', () => {
const sendMock = vi.fn()
const webContentsOnMock = vi.fn()
+36 -1
View File
@@ -1,10 +1,12 @@
import { app, clipboard, ipcMain } from 'electron'
import { app, clipboard, ipcMain, session } from 'electron'
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { CreateWorktreeResult } from '../../shared/types'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerPtyHandlers } from '../ipc/pty'
import { browserManager } from '../browser/browser-manager'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
checkForUpdatesFromMenu,
@@ -38,6 +40,39 @@ export function attachMainWindowServices(
callback(allowedPermissions.has(permission))
}
)
const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION)
browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
// Why: the in-app browser is for dev previews and lightweight browsing, not
// trusted desktop-app privileges. Denying by default keeps arbitrary sites
// from silently escalating into camera/mic/notification prompts inside Orca.
callback(permission === 'fullscreen')
})
browserSession.setPermissionCheckHandler((_webContents, permission) => {
return permission === 'fullscreen'
})
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
// Why: arbitrary sites inside Orca should never be able to capture the
// desktop or application windows until there is explicit product UX for
// selecting a source and surfacing that choice to the user.
// Why: pass undefined (not null) to satisfy Electron's typed callback
// signature while still denying the request.
callback({ video: undefined, audio: undefined })
})
browserSession.on('will-download', (event) => {
// Why: browser-tab downloads need explicit product UX before arbitrary sites
// can write files through Orca. Until that exists, cancel downloads instead
// of inheriting Electron's default save behavior invisibly.
event.preventDefault()
})
mainWindow.on('closed', () => {
// Why: parked browser webviews can outlive the visible tab body until the
// renderer process exits. Clearing main-owned guest registrations on window
// close prevents stale tab→webContents ids from leaking across app relaunch
// or hot-reload cycles.
browserManager.unregisterAll()
})
}
function registerRuntimeWindowLifecycle(
+53 -3
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { browserWindowMock, openExternalMock } = vi.hoisted(() => ({
const { browserWindowMock, openExternalMock, attachGuestPoliciesMock } = vi.hoisted(() => ({
browserWindowMock: vi.fn(),
openExternalMock: vi.fn()
openExternalMock: vi.fn(),
attachGuestPoliciesMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -24,12 +25,19 @@ vi.mock('../../../resources/icon-dev.png?asset', () => ({
default: 'icon-dev'
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
attachGuestPolicies: attachGuestPoliciesMock
}
}))
import { createMainWindow } from './createMainWindow'
describe('createMainWindow', () => {
beforeEach(() => {
browserWindowMock.mockReset()
openExternalMock.mockReset()
attachGuestPoliciesMock.mockReset()
})
it('enables renderer sandboxing and opens external links safely', () => {
@@ -39,6 +47,8 @@ describe('createMainWindow', () => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn((handler) => {
windowHandlers.windowOpen = handler
}),
@@ -47,6 +57,11 @@ describe('createMainWindow', () => {
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
@@ -70,7 +85,7 @@ describe('createMainWindow', () => {
expect(windowHandlers.windowOpen({ url: 'not a url' })).toEqual({ action: 'deny' })
expect(openExternalMock).toHaveBeenCalledTimes(2)
expect(openExternalMock).toHaveBeenCalledWith('https://example.com')
expect(openExternalMock).toHaveBeenCalledWith('https://example.com/')
expect(openExternalMock).toHaveBeenCalledWith('http://localhost:3000/')
const preventDefault = vi.fn()
@@ -95,6 +110,27 @@ describe('createMainWindow', () => {
)
expect(fileNavigationPreventDefault).toHaveBeenCalledTimes(1)
expect(openExternalMock).toHaveBeenCalledTimes(4)
const allowBlankEvent = { preventDefault: vi.fn() }
const allowBlankPrefs = { partition: 'persist:orca-browser' }
windowHandlers['will-attach-webview'](
allowBlankEvent as never,
allowBlankPrefs as never,
{ src: 'data:text/html,' } as never
)
expect(allowBlankEvent.preventDefault).not.toHaveBeenCalled()
const denyInlineHtmlEvent = { preventDefault: vi.fn() }
windowHandlers['will-attach-webview'](
denyInlineHtmlEvent as never,
{ partition: 'persist:orca-browser' } as never,
{ src: 'data:text/html,<script>alert(1)</script>' } as never
)
expect(denyInlineHtmlEvent.preventDefault).toHaveBeenCalledTimes(1)
const guest = { marker: 'guest' }
windowHandlers['did-attach-webview']({} as never, guest as never)
expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest)
})
it('supports all minus key variants for terminal zoom out', () => {
@@ -104,12 +140,19 @@ describe('createMainWindow', () => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
@@ -152,12 +195,19 @@ describe('createMainWindow', () => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
+80 -23
View File
@@ -4,26 +4,12 @@ import { is } from '@electron-toolkit/utils'
import icon from '../../../resources/icon.png?asset'
import devIcon from '../../../resources/icon-dev.png?asset'
import type { Store } from '../persistence'
const LOCAL_ADDRESS_PATTERN =
/^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:\/.*)?$/i
function normalizeExternalUrl(rawUrl: string): string | null {
if (LOCAL_ADDRESS_PATTERN.test(rawUrl)) {
try {
return new URL(`http://${rawUrl}`).toString()
} catch {
return null
}
}
try {
const parsed = new URL(rawUrl)
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? rawUrl : null
} catch {
return null
}
}
import { browserManager } from '../browser/browser-manager'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
} from '../../shared/browser-url'
function isZoomInShortcut(input: Electron.Input): boolean {
return input.key === '=' || input.key === '+' || input.code === 'NumpadAdd'
@@ -46,6 +32,23 @@ function isZoomOutShortcut(input: Electron.Input): boolean {
)
}
function forceRepaint(window: BrowserWindow): void {
if (window.isDestroyed()) {
return
}
window.webContents.invalidate()
if (window.isMaximized() || window.isFullScreen()) {
return
}
const [width, height] = window.getSize()
window.setSize(width + 1, height)
setTimeout(() => {
if (!window.isDestroyed()) {
window.setSize(width, height)
}
}, 32)
}
export function createMainWindow(store: Store | null): BrowserWindow {
const mainWindow = new BrowserWindow({
width: 1200,
@@ -60,10 +63,26 @@ export function createMainWindow(store: Store | null): BrowserWindow {
icon: is.dev ? devIcon : icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true
sandbox: true,
webviewTag: true
}
})
if (process.platform === 'darwin') {
// Why: persistent parked webviews use separate compositor layers, and on
// recent macOS releases those layers can fail to repaint after occlusion or
// restore. Disabling main-window throttling and forcing a repaint on
// visibility transitions hardens Orca against the same black-surface
// failure mode seen during browser-tab restore and tab switching.
mainWindow.webContents.setBackgroundThrottling(false)
mainWindow.on('restore', () => {
forceRepaint(mainWindow)
})
mainWindow.on('show', () => {
forceRepaint(mainWindow)
})
}
mainWindow.webContents.on('dom-ready', () => {
mainWindow.webContents.setZoomLevel(store?.getUI().uiZoomLevel ?? 0)
})
@@ -82,18 +101,56 @@ export function createMainWindow(store: Store | null): BrowserWindow {
})
mainWindow.webContents.setWindowOpenHandler((details) => {
const externalUrl = normalizeExternalUrl(details.url)
const externalUrl = normalizeExternalBrowserUrl(details.url)
if (externalUrl) {
shell.openExternal(externalUrl)
}
return { action: 'deny' }
})
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
const src = typeof params.src === 'string' ? params.src : ''
const normalizedSrc = normalizeBrowserNavigationUrl(src)
const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : ''
// Why: arbitrary sites must stay inside an unprivileged guest surface. We
// fail closed here so a renderer bug cannot smuggle preload, Node, or a
// non-browser partition into the guest and widen the app privilege boundary.
// The one allowed data URL is Orca's inert blank-tab bootstrap page; deny
// every other data URL so the renderer cannot inject arbitrary inline HTML.
if (!normalizedSrc || partition !== ORCA_BROWSER_PARTITION) {
event.preventDefault()
return
}
delete webPreferences.preload
// Why: older Electron builds expose preloadURL alongside preload; delete
// both so the guest surface cannot inherit the main preload bridge.
delete (webPreferences as Record<string, unknown>).preloadURL
webPreferences.nodeIntegration = false
webPreferences.nodeIntegrationInSubFrames = false
webPreferences.enableBlinkFeatures = ''
webPreferences.disableBlinkFeatures = ''
webPreferences.webSecurity = true
webPreferences.allowRunningInsecureContent = false
webPreferences.contextIsolation = true
webPreferences.sandbox = true
webPreferences.partition = ORCA_BROWSER_PARTITION
})
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
// Why: popup and navigation policy must attach as soon as Chromium creates
// the guest webContents. Waiting until renderer-driven registration leaves
// a race where target=_blank or early redirects can bypass Orca's intended
// fallback behavior.
browserManager.attachGuestPolicies(guest)
})
// Block ALL in-window navigations to prevent remote pages from inheriting
// the privileged preload bridge (PTY, filesystem, etc.).
// In dev mode, allow navigations to the local dev server (e.g. HMR reloads).
mainWindow.webContents.on('will-navigate', (event, url) => {
const externalUrl = normalizeExternalUrl(url)
const externalUrl = normalizeExternalBrowserUrl(url)
if (externalUrl) {
const target = new URL(externalUrl)
+294
View File
@@ -0,0 +1,294 @@
import type {
BrowserLoadError,
CreateWorktreeResult,
DirEntry,
GlobalSettings,
GitBranchCompareResult,
GitConflictOperation,
GitDiffResult,
GitStatusEntry,
IssueInfo,
NotificationDispatchRequest,
NotificationDispatchResult,
OrcaHooks,
PersistedUIState,
PRCheckDetail,
PRComment,
PRInfo,
Repo,
SearchOptions,
SearchResult,
StatsSummary,
UpdateStatus,
Worktree,
WorktreeMeta,
WorktreeSetupLaunch,
WorkspaceSessionState
} from '../../shared/types'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScanState,
ClaudeUsageScope,
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
export type BrowserApi = {
registerGuest: (args: { browserTabId: string; webContentsId: number }) => Promise<void>
unregisterGuest: (args: { browserTabId: string }) => Promise<void>
openDevTools: (args: { browserTabId: string }) => Promise<boolean>
onGuestLoadFailed: (
callback: (args: { browserTabId: string; loadError: BrowserLoadError }) => void
) => () => void
}
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
}
export type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
}
export type StatsApi = {
getSummary: () => Promise<StatsSummary>
}
export type ClaudeUsageApi = {
getScanState: () => Promise<ClaudeUsageScanState>
setEnabled: (args: { enabled: boolean }) => Promise<ClaudeUsageScanState>
refresh: (args?: { force?: boolean }) => Promise<ClaudeUsageScanState>
getSummary: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
}) => Promise<ClaudeUsageSummary>
getDaily: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
}) => Promise<ClaudeUsageDailyPoint[]>
getBreakdown: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
kind: ClaudeUsageBreakdownKind
}) => Promise<ClaudeUsageBreakdownRow[]>
getRecentSessions: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
limit?: number
}) => Promise<ClaudeUsageSessionRow[]>
}
export type PreloadApi = {
repos: {
list: () => Promise<Repo[]>
add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise<Repo>
remove: (args: { repoId: string }) => Promise<void>
update: (args: {
repoId: string
updates: Partial<
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
>
}) => Promise<Repo>
pickFolder: () => Promise<string | null>
pickDirectory: () => Promise<string | null>
clone: (args: { url: string; destination: string }) => Promise<Repo>
cloneAbort: () => Promise<void>
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
getGitUsername: (args: { repoId: string }) => Promise<string>
getBaseRefDefault: (args: { repoId: string }) => Promise<string>
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
onChanged: (callback: () => void) => () => void
}
worktrees: {
list: (args: { repoId: string }) => Promise<Worktree[]>
listAll: () => Promise<Worktree[]>
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}) => Promise<CreateWorktreeResult>
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}
pty: {
spawn: (opts: {
cols: number
rows: number
cwd?: string
env?: Record<string, string>
}) => Promise<{ id: string }>
write: (id: string, data: string) => void
resize: (id: string, cols: number, rows: number) => void
kill: (id: string) => Promise<void>
hasChildProcesses: (id: string) => Promise<boolean>
onData: (callback: (data: { id: string; data: string }) => void) => () => void
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
}
gh: {
prForBranch: (args: { repoPath: string; branch: string }) => Promise<PRInfo | null>
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
prChecks: (args: {
repoPath: string
prNumber: number
headSha?: string
noCache?: boolean
}) => Promise<PRCheckDetail[]>
prComments: (args: {
repoPath: string
prNumber: number
noCache?: boolean
}) => Promise<PRComment[]>
resolveReviewThread: (args: {
repoPath: string
threadId: string
resolve: boolean
}) => Promise<boolean>
updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise<boolean>
mergePR: (args: {
repoPath: string
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
}) => Promise<{ ok: true } | { ok: false; error: string }>
checkOrcaStarred: () => Promise<boolean | null>
starOrca: () => Promise<boolean>
}
settings: {
get: () => Promise<GlobalSettings>
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
}
cli: {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
}
preflight: PreflightApi
notifications: {
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
openSystemSettings: () => Promise<void>
}
shell: {
openPath: (path: string) => Promise<void>
openUrl: (url: string) => Promise<void>
openFilePath: (path: string) => Promise<void>
openFileUri: (uri: string) => Promise<void>
pathExists: (path: string) => Promise<boolean>
pickImage: () => Promise<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
}
browser: BrowserApi
hooks: {
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
}
cache: {
getGitHub: () => Promise<{
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}>
setGitHub: (args: {
cache: {
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}
}) => Promise<void>
}
session: {
get: () => Promise<WorkspaceSessionState>
set: (args: WorkspaceSessionState) => Promise<void>
setSync: (args: WorkspaceSessionState) => void
}
updater: {
getVersion: () => Promise<string>
getStatus: () => Promise<UpdateStatus>
check: () => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
onStatus: (callback: (status: UpdateStatus) => void) => () => void
}
stats: StatsApi
claudeUsage: ClaudeUsageApi
fs: {
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
readFile: (args: {
filePath: string
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
writeFile: (args: { filePath: string; content: string }) => Promise<void>
createFile: (args: { filePath: string }) => Promise<void>
createDir: (args: { dirPath: string }) => Promise<void>
rename: (args: { oldPath: string; newPath: string }) => Promise<void>
deletePath: (args: { targetPath: string }) => Promise<void>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
stat: (args: {
filePath: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
listFiles: (args: { rootPath: string }) => Promise<string[]>
search: (args: SearchOptions) => Promise<SearchResult>
}
git: {
status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }>
conflictOperation: (args: { worktreePath: string }) => Promise<GitConflictOperation>
diff: (args: {
worktreePath: string
filePath: string
staged: boolean
}) => Promise<GitDiffResult>
branchCompare: (args: {
worktreePath: string
baseRef: string
}) => Promise<GitBranchCompareResult>
branchDiff: (args: {
worktreePath: string
compare: {
baseRef: string
baseOid: string
headOid: string
mergeBase: string
}
filePath: string
oldPath?: string
}) => Promise<GitDiffResult>
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
remoteFileUrl: (args: {
worktreePath: string
relativePath: string
line: number
}) => Promise<string | null>
}
ui: {
get: () => Promise<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
writeClipboardText: (text: string) => Promise<void>
onFileDrop: (
callback: (data: { path: string; target: 'editor' | 'terminal' }) => void
) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void
onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void
onWindowCloseRequested: (callback: () => void) => () => void
confirmWindowClose: () => void
}
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
getStatus: () => Promise<RuntimeStatus>
}
}
+3 -201
View File
@@ -1,43 +1,9 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type {
Repo,
Worktree,
WorktreeMeta,
CreateWorktreeArgs,
CreateWorktreeResult,
PRInfo,
PRCheckDetail,
PRComment,
IssueInfo,
GlobalSettings,
NotificationDispatchRequest,
NotificationDispatchResult,
OrcaHooks,
PersistedUIState,
WorkspaceSessionState,
WorktreeSetupLaunch,
UpdateStatus,
DirEntry,
GitBranchCompareResult,
GitConflictOperation,
GitStatusEntry,
GitDiffResult,
SearchOptions,
SearchResult,
StatsSummary
CreateWorktreeArgs
} from '../../shared/types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScanState,
ClaudeUsageScope,
ClaudeUsageSessionRow,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { PreloadApi } from './api-types'
type ReposApi = {
list: () => Promise<Repo[]>
@@ -142,174 +108,10 @@ type ShellApi = {
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
}
type HooksApi = {
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
}
type CacheApi = {
getGitHub: () => Promise<{
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}>
setGitHub: (args: {
cache: {
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
}
}) => Promise<void>
}
type SessionApi = {
get: () => Promise<WorkspaceSessionState>
set: (args: WorkspaceSessionState) => Promise<void>
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
setSync: (args: WorkspaceSessionState) => void
}
type UpdaterApi = {
getVersion: () => Promise<string>
getStatus: () => Promise<UpdateStatus>
check: () => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
onStatus: (callback: (status: UpdateStatus) => void) => () => void
}
type UIApi = {
get: () => Promise<PersistedUIState>
set: (args: Partial<PersistedUIState>) => Promise<void>
onOpenSettings: (callback: () => void) => () => void
onActivateWorktree: (
callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void
) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
writeClipboardText: (text: string) => Promise<void>
onFileDrop: (
callback: (data: { path: string; target: 'editor' | 'terminal' }) => void
) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void
onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void
onWindowCloseRequested: (callback: () => void) => () => void
confirmWindowClose: () => void
}
type RuntimeApi = {
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
getStatus: () => Promise<RuntimeStatus>
}
type FsApi = {
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
readFile: (args: {
filePath: string
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
writeFile: (args: { filePath: string; content: string }) => Promise<void>
createFile: (args: { filePath: string }) => Promise<void>
createDir: (args: { dirPath: string }) => Promise<void>
rename: (args: { oldPath: string; newPath: string }) => Promise<void>
deletePath: (args: { targetPath: string }) => Promise<void>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
stat: (args: {
filePath: string
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
listFiles: (args: { rootPath: string }) => Promise<string[]>
search: (args: SearchOptions) => Promise<SearchResult>
}
type GitApi = {
status: (args: { worktreePath: string }) => Promise<GitStatusResult>
conflictOperation: (args: { worktreePath: string }) => Promise<GitConflictOperation>
diff: (args: {
worktreePath: string
filePath: string
staged: boolean
}) => Promise<GitDiffResult>
branchCompare: (args: {
worktreePath: string
baseRef: string
}) => Promise<GitBranchCompareResult>
branchDiff: (args: {
worktreePath: string
compare: {
baseRef: string
baseOid: string
headOid: string
mergeBase: string
}
filePath: string
oldPath?: string
}) => Promise<GitDiffResult>
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
remoteFileUrl: (args: {
worktreePath: string
relativePath: string
line: number
}) => Promise<string | null>
}
type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
}
type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
}
type StatsApi = {
getSummary: () => Promise<StatsSummary>
}
type ClaudeUsageApi = {
getScanState: () => Promise<ClaudeUsageScanState>
setEnabled: (args: { enabled: boolean }) => Promise<ClaudeUsageScanState>
refresh: (args?: { force?: boolean }) => Promise<ClaudeUsageScanState>
getSummary: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
}) => Promise<ClaudeUsageSummary>
getDaily: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
}) => Promise<ClaudeUsageDailyPoint[]>
getBreakdown: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
kind: ClaudeUsageBreakdownKind
}) => Promise<ClaudeUsageBreakdownRow[]>
getRecentSessions: (args: {
scope: ClaudeUsageScope
range: ClaudeUsageRange
limit?: number
}) => Promise<ClaudeUsageSessionRow[]>
}
type Api = {
type Api = PreloadApi & {
repos: ReposApi
worktrees: WorktreesApi
pty: PtyApi
gh: GhApi
settings: SettingsApi
cli: CliApi
preflight: PreflightApi
notifications: NotificationsApi
shell: ShellApi
hooks: HooksApi
cache: CacheApi
session: SessionApi
updater: UpdaterApi
stats: StatsApi
claudeUsage: ClaudeUsageApi
fs: FsApi
git: GitApi
ui: UIApi
runtime: RuntimeApi
}
declare global {
+28
View File
@@ -305,6 +305,34 @@ const api = {
ipcRenderer.invoke('shell:copyFile', args)
},
browser: {
registerGuest: (args: { browserTabId: string; webContentsId: number }): Promise<void> =>
ipcRenderer.invoke('browser:registerGuest', args),
unregisterGuest: (args: { browserTabId: string }): Promise<void> =>
ipcRenderer.invoke('browser:unregisterGuest', args),
openDevTools: (args: { browserTabId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:openDevTools', args),
onGuestLoadFailed: (
callback: (args: {
browserTabId: string
loadError: { code: number; description: string; validatedUrl: string }
}) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: {
browserTabId: string
loadError: { code: number; description: string; validatedUrl: string }
}
) => callback(data)
ipcRenderer.on('browser:guest-load-failed', listener)
return () => ipcRenderer.removeListener('browser:guest-load-failed', listener)
}
},
hooks: {
check: (args: { repoId: string }): Promise<{ hasHooks: boolean; hooks: unknown }> =>
ipcRenderer.invoke('hooks:check', args)
+36 -6
View File
@@ -25,7 +25,7 @@ import {
import { getVisibleWorktreeIds } from './components/sidebar/visible-worktrees'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import type { PersistedOpenFile } from '../../shared/types'
import type { BrowserTab, PersistedOpenFile, WorkspaceVisibleTabType } from '../../shared/types'
import type { OpenFile } from './store/slices/editor'
const isMac = navigator.userAgent.includes('Mac')
@@ -36,11 +36,11 @@ const SIDEBAR_TRANSITION_MS = 200
function buildEditorSessionData(
openFiles: OpenFile[],
activeFileIdByWorktree: Record<string, string | null>,
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
): {
openFilesByWorktree: Record<string, PersistedOpenFile[]>
activeFileIdByWorktree: Record<string, string | null>
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
} {
const editFiles = openFiles.filter((f) => f.mode === 'edit')
const byWorktree: Record<string, PersistedOpenFile[]> = {}
@@ -61,6 +61,27 @@ function buildEditorSessionData(
}
}
function buildBrowserSessionData(
browserTabsByWorktree: Record<string, BrowserTab[]>,
activeBrowserTabIdByWorktree: Record<string, string | null>
): {
browserTabsByWorktree: Record<string, BrowserTab[]>
activeBrowserTabIdByWorktree: Record<string, string | null>
} {
return {
// Why: browser tabs persist only lightweight chrome state. Live guest
// webContents are recreated on restore, so loading is reset to false and
// transient errors are preserved only as last-known tab metadata.
browserTabsByWorktree: Object.fromEntries(
Object.entries(browserTabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => ({ ...tab, loading: false }))
])
),
activeBrowserTabIdByWorktree
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
@@ -101,6 +122,7 @@ function App(): React.JSX.Element {
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession)
const hydrateBrowserSession = useAppStore((s) => s.hydrateBrowserSession)
const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals)
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
const openModal = useAppStore((s) => s.openModal)
@@ -118,6 +140,8 @@ function App(): React.JSX.Element {
const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree)
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const activeBrowserTabIdByWorktree = useAppStore((s) => s.activeBrowserTabIdByWorktree)
// Right sidebar + editor state
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
@@ -158,6 +182,7 @@ function App(): React.JSX.Element {
hydratePersistedUI(persistedUI)
hydrateWorkspaceSession(session)
hydrateEditorSession(session)
hydrateBrowserSession(session)
await reconnectPersistedTerminals(abortController.signal)
syncZoomCSSVar()
}
@@ -208,6 +233,7 @@ function App(): React.JSX.Element {
hydratePersistedUI,
hydrateWorkspaceSession,
hydrateEditorSession,
hydrateBrowserSession,
reconnectPersistedTerminals
])
@@ -246,7 +272,8 @@ function App(): React.JSX.Element {
terminalLayoutsByTabId,
activeWorktreeIdsOnShutdown,
activeTabIdByWorktree,
...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree)
...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree),
...buildBrowserSessionData(browserTabsByWorktree, activeBrowserTabIdByWorktree)
})
}, 150)
@@ -261,7 +288,9 @@ function App(): React.JSX.Element {
openFiles,
activeFileIdByWorktree,
activeTabTypeByWorktree,
activeTabIdByWorktree
activeTabIdByWorktree,
browserTabsByWorktree,
activeBrowserTabIdByWorktree
])
// On shutdown, capture terminal scrollback buffers and flush to disk.
@@ -294,7 +323,8 @@ function App(): React.JSX.Element {
state.openFiles,
state.activeFileIdByWorktree,
state.activeTabTypeByWorktree
)
),
...buildBrowserSessionData(state.browserTabsByWorktree, state.activeBrowserTabIdByWorktree)
})
}
window.addEventListener('beforeunload', captureAndFlush)
+318 -37
View File
@@ -21,6 +21,7 @@ import {
} from './editor/editor-autosave'
import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
import EditorAutosaveController from './editor/EditorAutosaveController'
import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPane'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@@ -41,12 +42,19 @@ export default function Terminal(): React.JSX.Element | null {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const activeTabType = useAppStore((s) => s.activeTabType)
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
const setActiveFile = useAppStore((s) => s.setActiveFile)
const closeFile = useAppStore((s) => s.closeFile)
const closeAllFiles = useAppStore((s) => s.closeAllFiles)
const pinFile = useAppStore((s) => s.pinFile)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
const updateBrowserTabPageState = useAppStore((s) => s.updateBrowserTabPageState)
const setBrowserTabUrl = useAppStore((s) => s.setBrowserTabUrl)
const markFileDirty = useAppStore((s) => s.markFileDirty)
const setTabBarOrder = useAppStore((s) => s.setTabBarOrder)
@@ -68,6 +76,12 @@ export default function Terminal(): React.JSX.Element | null {
const worktreeFiles = activeWorktreeId
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
: []
const worktreeBrowserTabs = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
// Save confirmation dialog state
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
@@ -164,7 +178,7 @@ export default function Terminal(): React.JSX.Element | null {
// are open for this worktree. The user may have intentionally closed all
// terminal tabs while keeping editors open — auto-spawning a terminal would
// be disruptive.
if (tabs.length > 0 || worktreeFiles.length > 0) {
if (tabs.length > 0 || worktreeFiles.length > 0 || worktreeBrowserTabs.length > 0) {
if (initialTabCreationGuardRef.current === activeWorktreeId) {
initialTabCreationGuardRef.current = null
}
@@ -178,7 +192,14 @@ export default function Terminal(): React.JSX.Element | null {
}
initialTabCreationGuardRef.current = activeWorktreeId
createTab(activeWorktreeId)
}, [workspaceSessionReady, activeWorktreeId, tabs.length, worktreeFiles.length, createTab])
}, [
workspaceSessionReady,
activeWorktreeId,
tabs.length,
worktreeFiles.length,
worktreeBrowserTabs.length,
createTab
])
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) {
@@ -193,13 +214,15 @@ export default function Terminal(): React.JSX.Element | null {
const state = useAppStore.getState()
const currentTerminals = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditors = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
const currentBrowsers = state.browserTabsByWorktree[activeWorktreeId] ?? []
const stored = state.tabBarOrderByWorktree[activeWorktreeId]
const termIds = currentTerminals.map((t) => t.id)
const editorIds = currentEditors.map((f) => f.id)
const validIds = new Set([...termIds, ...editorIds])
const browserIds = currentBrowsers.map((tab) => tab.id)
const validIds = new Set([...termIds, ...editorIds, ...browserIds])
const base = (stored ?? []).filter((id) => validIds.has(id))
const inBase = new Set(base)
for (const id of [...termIds, ...editorIds]) {
for (const id of [...termIds, ...editorIds, ...browserIds]) {
if (!inBase.has(id)) {
base.push(id)
inBase.add(id)
@@ -211,6 +234,13 @@ export default function Terminal(): React.JSX.Element | null {
setTabBarOrder(activeWorktreeId, order)
}, [activeWorktreeId, createTab, setActiveTabType, setTabBarOrder])
const handleNewBrowserTab = useCallback(() => {
if (!activeWorktreeId) {
return
}
createBrowserTab(activeWorktreeId, 'about:blank', { title: 'New Browser Tab' })
}, [activeWorktreeId, createBrowserTab])
const handleCloseTab = useCallback(
(tabId: string) => {
const state = useAppStore.getState()
@@ -235,7 +265,13 @@ export default function Terminal(): React.JSX.Element | null {
setActiveFile(worktreeFile.id)
setActiveTabType('editor')
} else {
setActiveWorktree(null)
const browserTab = (state.browserTabsByWorktree[owningWorktreeId] ?? [])[0]
if (browserTab) {
setActiveBrowserTab(browserTab.id)
setActiveTabType('browser')
} else {
setActiveWorktree(null)
}
}
}
return
@@ -251,7 +287,65 @@ export default function Terminal(): React.JSX.Element | null {
}
closeTab(tabId)
},
[closeTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree]
[
closeTab,
setActiveBrowserTab,
setActiveTab,
setActiveFile,
setActiveTabType,
setActiveWorktree
]
)
const handleCloseBrowserTab = useCallback(
(tabId: string) => {
const state = useAppStore.getState()
const owningWorktreeEntry = Object.entries(state.browserTabsByWorktree).find(
([, worktreeTabs]) => worktreeTabs.some((tab) => tab.id === tabId)
)
const owningWorktreeId = owningWorktreeEntry?.[0] ?? null
if (!owningWorktreeId) {
return
}
const currentTabs = state.browserTabsByWorktree[owningWorktreeId] ?? []
if (currentTabs.length <= 1) {
destroyPersistentWebview(tabId)
closeBrowserTab(tabId)
if (state.activeWorktreeId === owningWorktreeId) {
const worktreeFile = state.openFiles.find((file) => file.worktreeId === owningWorktreeId)
if (worktreeFile) {
setActiveFile(worktreeFile.id)
setActiveTabType('editor')
} else {
const terminalTab = (state.tabsByWorktree[owningWorktreeId] ?? [])[0]
if (terminalTab) {
setActiveTab(terminalTab.id)
setActiveTabType('terminal')
} else {
setActiveWorktree(null)
}
}
}
return
}
if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeBrowserTabId) {
const idx = currentTabs.findIndex((tab) => tab.id === tabId)
const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1]
if (nextTab) {
setActiveBrowserTab(nextTab.id)
}
}
destroyPersistentWebview(tabId)
closeBrowserTab(tabId)
},
[
closeBrowserTab,
setActiveBrowserTab,
setActiveFile,
setActiveTab,
setActiveTabType,
setActiveWorktree
]
)
const handlePtyExit = useCallback(
@@ -269,15 +363,33 @@ export default function Terminal(): React.JSX.Element | null {
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
setActiveTab(tabId)
for (const tab of currentTabs) {
if (tab.id !== tabId) {
closeTab(tab.id)
const state = useAppStore.getState()
const order = state.tabBarOrderByWorktree[activeWorktreeId] ?? []
for (const id of order) {
if (id === tabId) {
continue
}
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
closeTab(id)
} else if (
state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id)
) {
if (
state.activeFileId === id &&
state.openFiles.find((file) => file.id === id)?.isDirty
) {
continue
}
closeFile(id)
} else if (
(state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)
) {
destroyPersistentWebview(id)
closeBrowserTab(id)
}
}
},
[activeWorktreeId, closeTab, setActiveTab]
[activeWorktreeId, closeBrowserTab, closeFile, closeTab]
)
const handleCloseTabsToRight = useCallback(
@@ -285,17 +397,29 @@ export default function Terminal(): React.JSX.Element | null {
if (!activeWorktreeId) {
return
}
const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? []
const index = currentTabs.findIndex((t) => t.id === tabId)
const state = useAppStore.getState()
const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? []
const index = currentOrder.findIndex((id) => id === tabId)
if (index === -1) {
return
}
const rightTabs = currentTabs.slice(index + 1)
for (const tab of rightTabs) {
closeTab(tab.id)
const rightIds = currentOrder.slice(index + 1)
for (const id of rightIds) {
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
closeTab(id)
} else if (
state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id)
) {
closeFile(id)
} else if (
(state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)
) {
destroyPersistentWebview(id)
closeBrowserTab(id)
}
}
},
[activeWorktreeId, closeTab]
[activeWorktreeId, closeBrowserTab, closeFile, closeTab]
)
const handleActivateTab = useCallback(
@@ -320,6 +444,28 @@ export default function Terminal(): React.JSX.Element | null {
[setActiveTab]
)
const handleActivateBrowserTab = useCallback(
(tabId: string) => {
setActiveBrowserTab(tabId)
setActiveTabType('browser')
},
[setActiveBrowserTab, setActiveTabType]
)
const handleBrowserTabPageStateUpdate = useCallback(
(tabId: string, updates: Parameters<typeof updateBrowserTabPageState>[1]) => {
updateBrowserTabPageState(tabId, updates)
},
[updateBrowserTabPageState]
)
const handleBrowserTabSetUrl = useCallback(
(tabId: string, url: string) => {
setBrowserTabUrl(tabId, url)
},
[setBrowserTabUrl]
)
// Keyboard shortcuts
useEffect(() => {
if (!activeWorktreeId) {
@@ -329,14 +475,21 @@ export default function Terminal(): React.JSX.Element | null {
const isMac = navigator.userAgent.includes('Mac')
const onKeyDown = (e: KeyboardEvent): void => {
const mod = isMac ? e.metaKey : e.ctrlKey
// Cmd/Ctrl+T - new tab
// Cmd/Ctrl+T - new terminal tab
if (mod && e.key === 't' && !e.shiftKey && !e.repeat) {
e.preventDefault()
handleNewTab()
return
}
// Cmd/Ctrl+W - close active editor tab or terminal pane.
// Cmd/Ctrl+Shift+B - new browser tab
if (mod && e.shiftKey && e.key.toLowerCase() === 'b' && !e.repeat) {
e.preventDefault()
handleNewBrowserTab()
return
}
// Cmd/Ctrl+W - close active editor tab, browser tab, or terminal pane.
// Terminal pane/tab close is handled by the pane-level keyboard handler
// in keyboard-handlers.ts so it can close individual split panes and
// show a confirmation dialog. We still preventDefault here so Electron
@@ -346,6 +499,8 @@ export default function Terminal(): React.JSX.Element | null {
const state = useAppStore.getState()
if (state.activeTabType === 'editor' && state.activeFileId) {
handleCloseFile(state.activeFileId)
} else if (state.activeTabType === 'browser' && state.activeBrowserTabId) {
handleCloseBrowserTab(state.activeBrowserTabId)
}
return
}
@@ -354,26 +509,44 @@ export default function Terminal(): React.JSX.Element | null {
if (mod && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const state = useAppStore.getState()
const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
const currentEditorFiles = activeWorktreeId
? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
: []
// Build unified tab list: terminal tabs then editor tabs
const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [
...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })),
...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id }))
]
const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
const currentBrowserTabs = state.browserTabsByWorktree[activeWorktreeId] ?? []
const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? []
const allTabIds = currentOrder
.map((id) => {
if (currentTerminalTabs.some((tab) => tab.id === id)) {
return { type: 'terminal' as const, id }
}
if (currentEditorFiles.some((file) => file.id === id)) {
return { type: 'editor' as const, id }
}
if (currentBrowserTabs.some((tab) => tab.id === id)) {
return { type: 'browser' as const, id }
}
return null
})
.filter(
(value): value is { type: 'terminal' | 'editor' | 'browser'; id: string } =>
value !== null
)
if (allTabIds.length > 1) {
e.preventDefault()
const currentId =
state.activeTabType === 'editor' ? state.activeFileId : state.activeTabId
state.activeTabType === 'editor'
? state.activeFileId
: state.activeTabType === 'browser'
? state.activeBrowserTabId
: state.activeTabId
const idx = allTabIds.findIndex((t) => t.id === currentId)
const dir = e.key === ']' ? 1 : -1
const next = allTabIds[(idx + dir + allTabIds.length) % allTabIds.length]
if (next.type === 'terminal') {
setActiveTab(next.id)
state.setActiveTabType('terminal')
} else if (next.type === 'browser') {
state.setActiveBrowserTab(next.id)
state.setActiveTabType('browser')
} else {
state.setActiveFile(next.id)
state.setActiveTabType('editor')
@@ -383,7 +556,15 @@ export default function Terminal(): React.JSX.Element | null {
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [activeWorktreeId, handleNewTab, handleCloseTab, handleCloseFile, setActiveTab])
}, [
activeWorktreeId,
handleNewBrowserTab,
handleNewTab,
handleCloseTab,
handleCloseBrowserTab,
handleCloseFile,
setActiveTab
])
// Warn on window close if there are unsaved editor files
useEffect(() => {
@@ -429,6 +610,59 @@ export default function Terminal(): React.JSX.Element | null {
})
}, [])
// Why: removeWorktree cleans up browser tab state in the store but cannot
// call destroyPersistentWebview (renderer-only DOM code). This subscriber
// detects when browser tabs disappear from a worktree (e.g. worktree deleted)
// and destroys orphaned webview elements to prevent memory leaks.
const prevBrowserTabIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
return useAppStore.subscribe((state) => {
const currentIds = new Set(
Object.values(state.browserTabsByWorktree)
.flat()
.map((tab) => tab.id)
)
for (const prevId of prevBrowserTabIdsRef.current) {
if (!currentIds.has(prevId)) {
destroyPersistentWebview(prevId)
}
}
prevBrowserTabIdsRef.current = currentIds
})
}, [])
// Why: defensive guard against state inconsistency. If activeTabType is
// 'browser' but no browser tab can be rendered (e.g. activeBrowserTabId is
// null or doesn't match any tab), fall back to terminal view instead of
// rendering a blank screen. This runs as an effect (not during render)
// because calling Zustand mutations during render interferes with React's
// render cycle and causes blank screens when creating new tabs.
useEffect(() => {
const activeWorktreeBrowserTabs = activeWorktreeId
? (useAppStore.getState().browserTabsByWorktree[activeWorktreeId] ?? [])
: []
if (
activeTabType === 'browser' &&
activeWorktreeId &&
(!activeBrowserTabId ||
!activeWorktreeBrowserTabs.some((tab) => tab.id === activeBrowserTabId))
) {
const fallbackBrowserTab = activeWorktreeBrowserTabs[0]
if (fallbackBrowserTab) {
setActiveBrowserTab(fallbackBrowserTab.id)
} else {
setActiveTabType('terminal')
}
}
}, [
activeTabType,
activeWorktreeId,
activeBrowserTabId,
activeWorktreeBrowserTabIdsKey,
setActiveBrowserTab,
setActiveTabType
])
return (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${activeWorktreeId ? '' : ' hidden'}`}
@@ -450,19 +684,24 @@ export default function Terminal(): React.JSX.Element | null {
onCloseOthers={handleCloseOthers}
onCloseToRight={handleCloseTabsToRight}
onReorder={setTabBarOrder}
onNewTab={handleNewTab}
onNewTerminalTab={handleNewTab}
onNewBrowserTab={handleNewBrowserTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
expandedPaneByTabId={expandedPaneByTabId}
onTogglePaneExpand={handleTogglePaneExpand}
editorFiles={worktreeFiles}
browserTabs={worktreeBrowserTabs}
activeFileId={activeFileId}
activeBrowserTabId={activeBrowserTabId}
activeTabType={activeTabType}
onActivateFile={(fileId) => {
setActiveFile(fileId)
setActiveTabType('editor')
}}
onCloseFile={handleCloseFile}
onActivateBrowserTab={handleActivateBrowserTab}
onCloseBrowserTab={handleCloseBrowserTab}
onCloseAllFiles={closeAllFiles}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
@@ -472,7 +711,17 @@ export default function Terminal(): React.JSX.Element | null {
{/* Terminal panes container - hidden when editor tab active */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && worktreeFiles.length > 0 ? 'hidden' : ''}`}
className={`relative flex-1 min-h-0 overflow-hidden ${
// Why: only hide the terminal container when another tab type has
// content to display. Hiding unconditionally for non-terminal types
// causes a blank screen when activeTabType is stale (e.g. 'editor'
// with no files after session restore). The terminal stays visible
// as a fallback until another surface is ready.
(activeTabType === 'editor' && worktreeFiles.length > 0) ||
(activeTabType === 'browser' && worktreeBrowserTabs.length > 0)
? 'hidden'
: ''
}`}
>
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
@@ -502,10 +751,42 @@ export default function Terminal(): React.JSX.Element | null {
})}
</div>
{/* Why: v1.0.85 only mounted the visible editor surface, which kept
hidden editor effects out of app shutdown. Autosave now lives in the
narrow EditorAutosaveController above, so the full EditorPanel can go
back to the safer "mount only while visible" lifecycle. */}
{/* Browser panes container — hidden when active tab is not a browser tab.
Only the active browser tab for the active worktree is mounted; others
are parked in a hidden off-screen container by BrowserPane to preserve
their webview guest process across tab switches. */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType !== 'browser' ? 'hidden' : ''}`}
>
{allWorktrees.map((worktree) => {
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
const isVisibleWorktree = activeView !== 'settings' && worktree.id === activeWorktreeId
if (browserTabs.length === 0) {
return null
}
return (
<div
key={`browser-${worktree.id}`}
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisibleWorktree}
>
{isVisibleWorktree && activeTabType === 'browser'
? browserTabs
.filter((browserTab) => browserTab.id === activeBrowserTabId)
.map((browserTab) => (
<BrowserPane
key={browserTab.id}
browserTab={browserTab}
onUpdatePageState={handleBrowserTabPageStateUpdate}
onSetUrl={handleBrowserTabSetUrl}
/>
))
: null}
</div>
)
})}
</div>
{activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
<Suspense
fallback={
@@ -0,0 +1,775 @@
/* eslint-disable max-lines */
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ArrowLeft,
ArrowRight,
ExternalLink,
Globe,
Loader2,
RefreshCw,
SquareCode
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants'
import type { BrowserLoadError, BrowserTab as BrowserTabState } from '../../../../shared/types'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
} from '../../../../shared/browser-url'
import {
clearLiveBrowserUrl,
consumeEvictedBrowserTab,
markEvictedBrowserTab,
rememberLiveBrowserUrl
} from './browser-runtime'
type BrowserTabPageState = Partial<
Pick<
BrowserTabState,
'title' | 'loading' | 'faviconUrl' | 'canGoBack' | 'canGoForward' | 'loadError'
>
>
const webviewRegistry = new Map<string, Electron.WebviewTag>()
const registeredWebContentsIds = new Map<string, number>()
const parkedAtByTabId = new Map<string, number>()
let hiddenContainer: HTMLDivElement | null = null
const DRAG_LISTENER_KEY = '__orcaBrowserPaneDragListeners'
const MAX_PARKED_WEBVIEWS = 6
function getHiddenContainer(): HTMLDivElement {
if (!hiddenContainer) {
hiddenContainer = document.createElement('div')
hiddenContainer.style.position = 'fixed'
hiddenContainer.style.left = '-9999px'
hiddenContainer.style.top = '-9999px'
hiddenContainer.style.width = '100vw'
hiddenContainer.style.height = '100vh'
hiddenContainer.style.overflow = 'hidden'
hiddenContainer.style.pointerEvents = 'none'
document.body.appendChild(hiddenContainer)
}
return hiddenContainer
}
function setWebviewsDragPassthrough(passthrough: boolean): void {
for (const webview of webviewRegistry.values()) {
webview.style.pointerEvents = passthrough ? 'none' : ''
}
}
if (typeof window !== 'undefined') {
type DragListenerRegistry = {
dragstart: () => void
dragend: () => void
drop: () => void
}
const listenerHost = window as Window & { [DRAG_LISTENER_KEY]?: DragListenerRegistry }
const existingListeners = listenerHost[DRAG_LISTENER_KEY]
if (existingListeners) {
window.removeEventListener('dragstart', existingListeners.dragstart, true)
window.removeEventListener('dragend', existingListeners.dragend, true)
window.removeEventListener('drop', existingListeners.drop, true)
}
const dragstart = (): void => setWebviewsDragPassthrough(true)
const dragend = (): void => setWebviewsDragPassthrough(false)
const drop = (): void => setWebviewsDragPassthrough(false)
window.addEventListener('dragstart', dragstart, true)
window.addEventListener('dragend', dragend, true)
window.addEventListener('drop', drop, true)
// Why: BrowserPane installs process-wide drag listeners so parked webviews
// stop swallowing drop targets. We store/remove the previous handlers on
// window to keep Vite HMR from stacking duplicates across module reloads.
listenerHost[DRAG_LISTENER_KEY] = { dragstart, dragend, drop }
}
export function destroyPersistentWebview(browserTabId: string): void {
const webview = webviewRegistry.get(browserTabId)
if (!webview) {
registeredWebContentsIds.delete(browserTabId)
parkedAtByTabId.delete(browserTabId)
clearLiveBrowserUrl(browserTabId)
return
}
void window.api.browser.unregisterGuest({ browserTabId })
webview.remove()
webviewRegistry.delete(browserTabId)
registeredWebContentsIds.delete(browserTabId)
parkedAtByTabId.delete(browserTabId)
clearLiveBrowserUrl(browserTabId)
}
function buildLoadError(event: {
errorCode?: number
errorDescription?: string
validatedURL?: string
}): BrowserLoadError {
return {
code: event.errorCode ?? -1,
description: event.errorDescription ?? 'Unknown load failure',
validatedUrl: event.validatedURL ?? 'about:blank'
}
}
function toDisplayUrl(url: string): string {
return url === ORCA_BROWSER_BLANK_URL ? 'about:blank' : url
}
function isChromiumErrorPage(url: string): boolean {
return url.startsWith('chrome-error://')
}
function getLoadErrorMetadata(loadError: BrowserLoadError | null): {
displayUrl: string
host: string | null
isLocalhostLike: boolean
} {
const rawUrl = loadError?.validatedUrl ?? 'about:blank'
const displayUrl = toDisplayUrl(rawUrl)
try {
const parsed = new URL(rawUrl)
const host = parsed.host || null
const hostname = parsed.hostname
const isLocalhostLike =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '0.0.0.0' ||
hostname === '::1'
return { displayUrl, host, isLocalhostLike }
} catch {
return { displayUrl, host: null, isLocalhostLike: false }
}
}
function getFriendlyLoadErrorDescription(loadError: BrowserLoadError | null): string {
if (!loadError) {
return 'The page did not respond.'
}
if (loadError.code === 0) {
return loadError.description
}
return "We couldn't connect to this page."
}
function getOpenableExternalUrl(
webview: Electron.WebviewTag | null,
fallbackUrl: string
): string | null {
let currentUrl = fallbackUrl
if (webview) {
try {
currentUrl = webview.getURL() || fallbackUrl
} catch {
// Why: restored browser tabs render before the guest emits dom-ready.
// Electron throws if toolbar code queries navigation state too early, and
// that renderer exception blanks the whole IDE on launch. Fall back to the
// persisted tab URL until the guest is fully attached.
currentUrl = fallbackUrl
}
}
return normalizeExternalBrowserUrl(currentUrl)
}
function retryBrowserTabLoad(
webview: Electron.WebviewTag | null,
browserTab: BrowserTabState,
onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void
): void {
if (!webview) {
return
}
const retryUrl = normalizeBrowserNavigationUrl(
browserTab.loadError?.validatedUrl ?? browserTab.url
)
if (!retryUrl) {
return
}
// Why: once Chromium lands on chrome-error://chromewebdata/, reload() can
// simply refresh the internal error page instead of retrying the original
// destination. Force navigation back to the attempted URL so Retry and the
// toolbar reload button actually re-attempt the failed page. Keep the last
// failure visible until a real success arrives so retry does not briefly
// drop the user back to a blank black guest surface.
onUpdatePageState(browserTab.id, {
loading: true,
title: retryUrl
})
webview.src = retryUrl
}
function evictParkedWebviews(excludedTabId: string | null = null): void {
if (webviewRegistry.size <= MAX_PARKED_WEBVIEWS) {
return
}
const hidden = getHiddenContainer()
const parkedBrowserTabIds = [...webviewRegistry.entries()]
.filter(
([browserTabId, webview]) =>
browserTabId !== excludedTabId && webview.parentElement === hidden
)
.sort((a, b) => (parkedAtByTabId.get(a[0]) ?? 0) - (parkedAtByTabId.get(b[0]) ?? 0))
.map(([browserTabId]) => browserTabId)
while (webviewRegistry.size > MAX_PARKED_WEBVIEWS && parkedBrowserTabIds.length > 0) {
const browserTabId = parkedBrowserTabIds.shift()
if (browserTabId) {
// Why: browser tabs are persistent for fast switching, but hidden guests
// cannot grow without bound or long Orca sessions accumulate Chromium
// processes and GPU surfaces. Evict only parked webviews, never the
// currently visible guest. Remember the eviction so the next mount can
// explain why an older tab had to reload instead of silently losing state.
markEvictedBrowserTab(browserTabId)
destroyPersistentWebview(browserTabId)
}
}
}
export default function BrowserPane({
browserTab,
onUpdatePageState,
onSetUrl
}: {
browserTab: BrowserTabState
onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void
onSetUrl: (tabId: string, url: string) => void
}): React.JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<Electron.WebviewTag | null>(null)
const faviconUrlRef = useRef<string | null>(browserTab.faviconUrl)
const initialBrowserUrlRef = useRef(browserTab.url)
const browserTabUrlRef = useRef(browserTab.url)
const activeLoadFailureRef = useRef<BrowserLoadError | null>(browserTab.loadError)
const trackNextLoadingEventRef = useRef(false)
const onUpdatePageStateRef = useRef(onUpdatePageState)
const onSetUrlRef = useRef(onSetUrl)
const [addressBarValue, setAddressBarValue] = useState(browserTab.url)
const addressBarValueRef = useRef(browserTab.url)
const [resourceNotice, setResourceNotice] = useState<string | null>(null)
useEffect(() => {
setAddressBarValue(toDisplayUrl(browserTab.url))
}, [browserTab.url])
useEffect(() => {
browserTabUrlRef.current = browserTab.url
}, [browserTab.url])
useEffect(() => {
activeLoadFailureRef.current = browserTab.loadError
}, [browserTab.loadError])
useEffect(() => {
addressBarValueRef.current = addressBarValue
}, [addressBarValue])
useEffect(() => {
setResourceNotice(
consumeEvictedBrowserTab(browserTab.id)
? 'This tab reloaded to free browser resources.'
: null
)
}, [browserTab.id])
useEffect(() => {
onUpdatePageStateRef.current = onUpdatePageState
onSetUrlRef.current = onSetUrl
}, [onSetUrl, onUpdatePageState])
const syncNavigationState = useCallback(
(webview: Electron.WebviewTag): void => {
try {
onUpdatePageStateRef.current(browserTab.id, {
title: webview.getTitle() || webview.getURL() || 'Browser',
// Why: webview reclaim/attach can transiently report isLoading() even
// when no user-visible navigation happened. If we sync that into the
// tab model on every activation, switching tabs flashes the blue
// loading dot and makes parked tabs look like they are reloading.
// Only explicit navigation/load events should drive Orca's loading UI.
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward()
})
} catch {
// Why: Electron only exposes these getters after the guest fully
// attaches. Ignoring the transient failure avoids crashing Orca while
// the parked webview is being reclaimed into the visible tab body.
}
},
[browserTab.id]
)
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
let webview = webviewRegistry.get(browserTab.id)
let needsInitialNavigation = false
if (webview) {
container.appendChild(webview)
parkedAtByTabId.delete(browserTab.id)
syncNavigationState(webview)
} else {
webview = document.createElement('webview') as Electron.WebviewTag
webview.setAttribute('partition', ORCA_BROWSER_PARTITION)
webview.setAttribute('allowpopups', '')
webview.style.display = 'flex'
webview.style.flex = '1'
webview.style.width = '100%'
webview.style.height = '100%'
webview.style.border = 'none'
webview.style.background = 'transparent'
webviewRegistry.set(browserTab.id, webview)
container.appendChild(webview)
needsInitialNavigation = true
}
webviewRef.current = webview
const handleDomReady = (): void => {
const webContentsId = webview.getWebContentsId()
if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) {
registeredWebContentsIds.set(browserTab.id, webContentsId)
void window.api.browser.registerGuest({
browserTabId: browserTab.id,
webContentsId
})
}
syncNavigationState(webview)
}
const handleDidStartLoading = (): void => {
if (!trackNextLoadingEventRef.current) {
return
}
faviconUrlRef.current = null
onUpdatePageStateRef.current(browserTab.id, {
loading: true,
faviconUrl: null
})
}
const handleDidStopLoading = (): void => {
const currentUrl = webview.getURL() || webview.src || 'about:blank'
const activeLoadFailure = activeLoadFailureRef.current
if (isChromiumErrorPage(currentUrl)) {
trackNextLoadingEventRef.current = false
const synthesizedFailure = {
code: -1,
description: 'This site could not be reached.',
validatedUrl: browserTabUrlRef.current || addressBarValueRef.current || 'about:blank'
}
activeLoadFailureRef.current = synthesizedFailure
onUpdatePageStateRef.current(browserTab.id, {
loading: false,
loadError: synthesizedFailure
})
return
}
if (activeLoadFailure) {
const normalizedAttemptedUrl =
normalizeBrowserNavigationUrl(activeLoadFailure.validatedUrl) ??
activeLoadFailure.validatedUrl
const normalizedCurrentUrl = normalizeBrowserNavigationUrl(currentUrl) ?? currentUrl
if (normalizedAttemptedUrl === normalizedCurrentUrl) {
trackNextLoadingEventRef.current = false
// Why: some webview failures still emit did-stop-loading on the
// original destination URL. If we clear loadError here, the failed
// navigation falls back to a blank Chromium surface even though Orca
// already knows this exact load failed.
onUpdatePageStateRef.current(browserTab.id, {
loading: false,
title: webview.getTitle() || currentUrl,
faviconUrl: faviconUrlRef.current,
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward(),
loadError: activeLoadFailure
})
return
}
}
trackNextLoadingEventRef.current = false
activeLoadFailureRef.current = null
rememberLiveBrowserUrl(browserTab.id, currentUrl)
setAddressBarValue(toDisplayUrl(currentUrl))
onSetUrlRef.current(browserTab.id, currentUrl)
onUpdatePageStateRef.current(browserTab.id, {
loading: false,
title: webview.getTitle() || currentUrl,
faviconUrl: faviconUrlRef.current,
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward(),
loadError: null
})
}
const handleDidNavigate = (event: { url?: string; isMainFrame?: boolean }): void => {
if (event.isMainFrame === false) {
return
}
const currentUrl = event.url ?? webview.getURL() ?? webview.src ?? 'about:blank'
if (isChromiumErrorPage(currentUrl)) {
return
}
rememberLiveBrowserUrl(browserTab.id, currentUrl)
setAddressBarValue(toDisplayUrl(currentUrl))
onSetUrlRef.current(browserTab.id, currentUrl)
onUpdatePageStateRef.current(browserTab.id, {
title: webview.getTitle() || currentUrl,
canGoBack: webview.canGoBack(),
canGoForward: webview.canGoForward()
})
}
const handleTitleUpdate = (event: { title?: string }): void => {
onUpdatePageStateRef.current(browserTab.id, {
title: event.title ?? webview.getURL() ?? 'Browser'
})
}
const handleFaviconUpdate = (event: { favicons?: string[] }): void => {
const faviconUrl = event.favicons?.[0] ?? null
faviconUrlRef.current =
faviconUrl &&
(faviconUrl.startsWith('https://') ||
faviconUrl.startsWith('http://') ||
faviconUrl.startsWith('data:image/'))
? faviconUrl
: null
onUpdatePageStateRef.current(browserTab.id, { faviconUrl: faviconUrlRef.current })
}
const handleFailLoad = (event: {
errorCode?: number
errorDescription?: string
validatedURL?: string
isMainFrame?: boolean
}): void => {
if (event.isMainFrame === false) {
return
}
if (event.errorCode === -3) {
// Why: Chromium reports redirect/cancel races as ERR_ABORTED (-3) even
// when the replacement navigation succeeds. Ignore that noise so Orca
// does not show a false load failure for a working page.
return
}
trackNextLoadingEventRef.current = false
const loadError = buildLoadError(event)
activeLoadFailureRef.current = loadError
onUpdatePageStateRef.current(browserTab.id, {
loading: false,
loadError
})
}
webview.addEventListener('dom-ready', handleDomReady)
webview.addEventListener('did-start-loading', handleDidStartLoading)
webview.addEventListener('did-stop-loading', handleDidStopLoading)
webview.addEventListener('did-navigate', handleDidNavigate)
webview.addEventListener('did-navigate-in-page', handleDidNavigate)
webview.addEventListener('page-title-updated', handleTitleUpdate)
webview.addEventListener('page-favicon-updated', handleFaviconUpdate)
webview.addEventListener('did-fail-load', handleFailLoad)
if (needsInitialNavigation) {
// Why: connection-refused localhost tabs can fail before Electron wires up
// event delivery if src is assigned too early. Attach listeners first so
// Orca never misses the initial did-fail-load signal for a new tab.
// Only non-blank initial tabs should light up Orca's loading indicator;
// reclaiming/activating a parked about:blank tab is not a meaningful
// navigation and should not flash the tab-loading dot.
trackNextLoadingEventRef.current =
(normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL) !==
ORCA_BROWSER_BLANK_URL
webview.src =
normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL
}
return () => {
webview.removeEventListener('dom-ready', handleDomReady)
webview.removeEventListener('did-start-loading', handleDidStartLoading)
webview.removeEventListener('did-stop-loading', handleDidStopLoading)
webview.removeEventListener('did-navigate', handleDidNavigate)
webview.removeEventListener('did-navigate-in-page', handleDidNavigate)
webview.removeEventListener('page-title-updated', handleTitleUpdate)
webview.removeEventListener('page-favicon-updated', handleFaviconUpdate)
webview.removeEventListener('did-fail-load', handleFailLoad)
if (webviewRef.current === webview) {
webviewRef.current = null
}
if (webviewRegistry.get(browserTab.id) === webview) {
getHiddenContainer().appendChild(webview)
parkedAtByTabId.set(browserTab.id, Date.now())
evictParkedWebviews(browserTab.id)
}
}
}, [browserTab.id, syncNavigationState])
useEffect(() => {
const webview = webviewRef.current
if (!webview) {
return
}
const normalizedUrl = normalizeBrowserNavigationUrl(browserTab.url)
if (!normalizedUrl) {
return
}
if (webview.src !== normalizedUrl && webview.getAttribute('src') !== normalizedUrl) {
// Why: browserTab.url changes are Orca-driven navigations (address bar,
// terminal link open, retry target update). Gate the next did-start-loading
// event so only real navigations, not tab activation churn, show loading UI.
trackNextLoadingEventRef.current = normalizedUrl !== ORCA_BROWSER_BLANK_URL
webview.src = normalizedUrl
}
}, [browserTab.url])
useEffect(() => {
if (!browserTab.loading) {
return
}
const detectChromiumErrorPage = (): void => {
const webview = webviewRef.current
if (!webview) {
return
}
try {
const currentUrl = webview.getURL() || webview.src || ''
if (!isChromiumErrorPage(currentUrl)) {
return
}
const attemptedUrl = browserTabUrlRef.current || addressBarValueRef.current || 'about:blank'
onUpdatePageStateRef.current(browserTab.id, {
loading: false,
loadError: {
code: -1,
description: 'This site could not be reached.',
validatedUrl: attemptedUrl
}
})
} catch {
// Why: the guest can still be mid-attach while the loading spinner is
// visible. Polling is only a fallback for missed failure events, so
// transient getURL() errors should be ignored until the next tick.
}
}
// Why: some Electron builds paint Chromium's internal chrome-error page
// without delivering a timely did-fail-load event to the renderer webview.
// Polling only while the tab is "loading" gives Orca a last-resort path to
// swap the black guest surface for the explicit unreachable-page overlay.
detectChromiumErrorPage()
const intervalId = window.setInterval(detectChromiumErrorPage, 250)
return () => window.clearInterval(intervalId)
}, [browserTab.id, browserTab.loading])
const submitAddressBar = (): void => {
const nextUrl = normalizeBrowserNavigationUrl(addressBarValue)
if (!nextUrl) {
onUpdatePageStateRef.current(browserTab.id, {
loadError: {
code: 0,
description: 'Enter a valid http(s) or localhost URL.',
validatedUrl: addressBarValue.trim() || 'about:blank'
}
})
return
}
setAddressBarValue(toDisplayUrl(nextUrl))
onSetUrlRef.current(browserTab.id, nextUrl)
onUpdatePageStateRef.current(browserTab.id, { loading: true, loadError: null, title: nextUrl })
setResourceNotice(null)
const webview = webviewRef.current
if (!webview) {
return
}
trackNextLoadingEventRef.current = nextUrl !== ORCA_BROWSER_BLANK_URL
webview.src = nextUrl
}
// Why: the store initially holds 'about:blank', but once the webview loads
// with the safe data: URL, handleDidStopLoading writes the resolved URL back.
// Match both so the "New Browser Tab" overlay stays visible for blank tabs.
const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL
const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url)
const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError)
const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab
useEffect(() => {
const webview = webviewRef.current
if (!webview) {
return
}
// Why: Electron webviews render in their own compositor layer, so a React
// overlay can sit "under" a failed guest and still look like a black page.
// Fully removing the guest from layout is more reliable than visibility
// toggles here; some Electron builds keep painting a hidden guest layer.
webview.style.display = showFailureOverlay ? 'none' : 'flex'
}, [showFailureOverlay])
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col">
<div className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-2">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => webviewRef.current?.goBack()}
disabled={!browserTab.canGoBack}
>
<ArrowLeft className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => webviewRef.current?.goForward()}
disabled={!browserTab.canGoForward}
>
<ArrowRight className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const webview = webviewRef.current
if (!webview) {
return
}
if (browserTab.loading) {
webview.stop()
} else if (browserTab.loadError) {
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
} else {
webview.reload()
}
}}
>
{browserTab.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
<form
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1.5 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
submitAddressBar()
}}
>
<Globe className="size-4 shrink-0 text-muted-foreground" />
<Input
value={addressBarValue}
onChange={(event) => setAddressBarValue(event.target.value)}
className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
/>
</form>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => void window.api.browser.openDevTools({ browserTabId: browserTab.id })}
title="Open browser devtools"
>
<SquareCode className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
if (!externalUrl) {
return
}
void window.api.shell.openUrl(externalUrl)
}}
title="Open in default browser"
disabled={!externalUrl}
>
<ExternalLink className="size-4" />
</Button>
</div>
{resourceNotice ? (
<div className="border-b border-border/60 bg-background px-3 py-1.5 text-xs text-muted-foreground">
{resourceNotice}
</div>
) : null}
<div
ref={containerRef}
className="relative flex min-h-0 flex-1 overflow-hidden bg-background"
>
{showFailureOverlay ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
<div className="flex max-w-sm flex-col items-center px-8 py-8 text-center opacity-70">
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
<Globe className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold text-foreground/85">
{loadErrorMeta.host ? `Can't reach ${loadErrorMeta.host}` : "Can't load this page"}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{getFriendlyLoadErrorDescription(browserTab.loadError)}
</p>
<div className="mt-5 flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-9 gap-2 px-3"
title="Retry"
onClick={() => {
const webview = webviewRef.current
if (!webview) {
return
}
onUpdatePageStateRef.current(browserTab.id, {
loading: true
})
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
}}
>
<RefreshCw className="size-4" />
<span>Refresh</span>
</Button>
</div>
</div>
</div>
) : null}
{isBlankTab ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
<div className="flex flex-col items-center px-8 py-8 text-center opacity-70">
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
<Globe className="size-5 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-base font-semibold text-foreground/85">New Browser Tab</p>
<p className="mt-2 text-sm text-muted-foreground">
Type a URL above to start browsing.
</p>
</div>
</div>
</div>
) : null}
</div>
</div>
)
}
@@ -0,0 +1,26 @@
const liveBrowserUrlByTabId = new Map<string, string>()
const evictedBrowserTabIds = new Set<string>()
export function rememberLiveBrowserUrl(browserTabId: string, url: string): void {
liveBrowserUrlByTabId.set(browserTabId, url)
}
export function getLiveBrowserUrl(browserTabId: string): string | null {
return liveBrowserUrlByTabId.get(browserTabId) ?? null
}
export function clearLiveBrowserUrl(browserTabId: string): void {
liveBrowserUrlByTabId.delete(browserTabId)
}
export function markEvictedBrowserTab(browserTabId: string): void {
evictedBrowserTabIds.add(browserTabId)
}
export function consumeEvictedBrowserTab(browserTabId: string): boolean {
const wasEvicted = evictedBrowserTabIds.has(browserTabId)
if (wasEvicted) {
evictedBrowserTabIds.delete(browserTabId)
}
return wasEvicted
}
@@ -158,6 +158,39 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
/>
</button>
</SearchableSetting>
<SearchableSetting
title="Open Links In Orca"
description="Open terminal http(s) links in Orca browser tabs instead of the system browser."
keywords={['browser', 'preview', 'links', 'localhost', 'webview']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Open Links In Orca</Label>
<p className="text-xs text-muted-foreground">
Open terminal http(s) links in isolated Orca browser tabs instead of the system
browser.
</p>
</div>
<button
role="switch"
aria-checked={settings.openLinksInApp}
onClick={() =>
updateSettings({
openLinksInApp: !settings.openLinksInApp
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.openLinksInApp ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
vi.mock('@/lib/agent-status', () => ({
detectAgentStatusFromTitle: vi.fn((title: string) => {
if (title.includes('permission')) {
return 'permission'
}
if (title.includes('working')) {
return 'working'
}
return null
})
}))
import { getWorktreeStatus } from './WorktreeCard'
function makeTerminalTab(title: string): TerminalTab {
return {
id: 'tab-1',
worktreeId: 'repo1::/tmp/wt',
ptyId: 'pty-1',
title,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
describe('getWorktreeStatus', () => {
it('treats browser-only worktrees as active', () => {
expect(getWorktreeStatus([], [{ id: 'browser-1' }])).toBe('active')
})
it('keeps terminal agent states higher priority than browser presence', () => {
expect(
getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }])
).toBe('permission')
expect(
getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }])
).toBe('working')
})
})
@@ -58,6 +58,25 @@ const CONFLICT_OPERATION_LABELS: Record<Exclude<GitConflictOperation, 'unknown'>
// ── Stable empty array for tabs fallback ─────────────────────────
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_BROWSER_TABS: { id: string }[] = []
export function getWorktreeStatus(tabs: TerminalTab[], browserTabs: { id: string }[]): Status {
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
return 'working'
}
if (liveTabs.length > 0 || browserTabs.length > 0) {
// Why: browser-only worktrees are still active from the user's point of
// view even when they have no PTY-backed terminal. The sidebar filter
// already treats them as active, so the card badge must stay consistent
// instead of showing a misleading inactive dot.
return 'active'
}
return 'inactive'
}
type WorktreeCardProps = {
worktree: Worktree
@@ -138,6 +157,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS)
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
@@ -155,23 +175,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
: undefined
: null
const hasTerminals = tabs.length > 0
const isDeleting = deleteState?.isDeleting ?? false
// Derive status
const status: Status = useMemo(() => {
if (!hasTerminals) {
return 'inactive'
}
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
return 'working'
}
return liveTabs.length > 0 ? 'active' : 'inactive'
}, [hasTerminals, tabs])
const status: Status = useMemo(() => getWorktreeStatus(tabs, browserTabs), [tabs, browserTabs])
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
@@ -38,6 +38,9 @@ const WorktreeList = React.memo(function WorktreeList() {
// Read tabsByWorktree when needed for filtering or sorting
const needsTabs = showActiveOnly || sortBy === 'recent'
const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null))
const browserTabsByWorktree = useAppStore((s) =>
showActiveOnly ? s.browserTabsByWorktree : null
)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
@@ -183,6 +186,8 @@ const WorktreeList = React.memo(function WorktreeList() {
searchQuery,
showActiveOnly,
tabsByWorktree,
browserTabsByWorktree,
activeWorktreeId,
repoMap,
prCache,
issueCache
@@ -200,8 +205,10 @@ const WorktreeList = React.memo(function WorktreeList() {
filterRepoIds,
searchQuery,
showActiveOnly,
activeWorktreeId,
repoMap,
tabsByWorktree,
browserTabsByWorktree,
sortedIds,
prCache,
issueCache
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { computeVisibleWorktreeIds } from './visible-worktrees'
import type { Repo, Worktree } from '../../../../shared/types'
function makeWorktree(id: string, repoId = 'repo1'): Worktree {
return {
id,
repoId,
path: `/tmp/${id}`,
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
sortOrder: 0,
lastActivityAt: 0
}
}
const repoMap = new Map<string, Repo>([
[
'repo1',
{
id: 'repo1',
path: '/repo1',
displayName: 'Repo 1',
badgeColor: '#000',
addedAt: 0
}
]
])
describe('computeVisibleWorktreeIds', () => {
it('treats browser-tab worktrees as active for the active-only filter', () => {
const wt = makeWorktree('wt-browser')
const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], {
filterRepoIds: [],
searchQuery: '',
showActiveOnly: true,
tabsByWorktree: {},
browserTabsByWorktree: { [wt.id]: [{ id: 'browser-1' }] },
activeWorktreeId: null,
repoMap,
prCache: null,
issueCache: null
})
expect(result).toEqual([wt.id])
})
it('keeps the currently active worktree visible even without PTYs', () => {
const wt = makeWorktree('wt-active')
const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], {
filterRepoIds: [],
searchQuery: '',
showActiveOnly: true,
tabsByWorktree: {},
browserTabsByWorktree: {},
activeWorktreeId: wt.id,
repoMap,
prCache: null,
issueCache: null
})
expect(result).toEqual([wt.id])
})
})
@@ -22,6 +22,8 @@ export function computeVisibleWorktreeIds(
searchQuery: string
showActiveOnly: boolean
tabsByWorktree: Record<string, TerminalTab[]> | null
browserTabsByWorktree?: Record<string, { id: string }[]> | null
activeWorktreeId?: string | null
repoMap: Map<string, Repo>
prCache: AppState['prCache'] | null
issueCache: AppState['issueCache'] | null
@@ -49,7 +51,13 @@ export function computeVisibleWorktreeIds(
if (opts.showActiveOnly) {
all = all.filter((w) => {
const tabs = opts.tabsByWorktree?.[w.id] ?? []
return tabs.some((t) => t.ptyId)
const hasLiveTerminal = tabs.some((t) => t.ptyId)
const hasBrowserTabs = (opts.browserTabsByWorktree?.[w.id] ?? []).length > 0
// Why: "Active only" should reflect the surfaces Orca can actually
// restore into, not just PTY-backed terminals. A browser-tab worktree is
// still active from the user's point of view even if it has no live PTY,
// and the currently selected worktree should never vanish from the list.
return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id
})
}
@@ -153,6 +161,8 @@ export function getVisibleWorktreeIds(): string[] {
searchQuery: state.searchQuery,
showActiveOnly: state.showActiveOnly,
tabsByWorktree: state.tabsByWorktree,
browserTabsByWorktree: state.browserTabsByWorktree,
activeWorktreeId: state.activeWorktreeId,
repoMap,
prCache: state.prCache,
issueCache: state.issueCache
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Globe, X, ExternalLink } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
import { getLiveBrowserUrl } from '../browser-pane/browser-runtime'
function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
return 'New Browser Tab'
}
try {
const parsed = new URL(url)
return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return url
}
}
function getBrowserTabLabel(tab: BrowserTabState): string {
if (
!tab.title ||
tab.title === tab.url ||
tab.title === ORCA_BROWSER_BLANK_URL ||
tab.title === 'about:blank'
) {
return formatBrowserTabUrlLabel(tab.url)
}
return tab.title || tab.url
}
function isBlankBrowserTab(tab: BrowserTabState): boolean {
return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank'
}
export default function BrowserTab({
tab,
isActive,
hasTabsToRight,
onActivate,
onClose,
onCloseToRight
}: {
tab: BrowserTabState
isActive: boolean
hasTabsToRight: boolean
onActivate: () => void
onClose: () => void
onCloseToRight: () => void
}): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id
})
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
// Why: about:blank and other non-http URLs should not be sent to the
// system browser. Disable the context menu item instead of silently
// calling shell.openUrl with an unsupported URL.
const openInBrowserUrl = getLiveBrowserUrl(tab.id) ?? tab.url
let isHttpUrl = false
try {
const parsed = new URL(openInBrowserUrl)
isHttpUrl = parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
// invalid URL — leave disabled
}
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
return (
<>
<div
onContextMenuCapture={(event) => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
setMenuPoint({ x: event.clientX, y: event.clientY })
setMenuOpen(true)
}}
>
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1
}}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
isActive
? 'bg-accent/40 text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
onActivate()
listeners?.onPointerDown?.(e)
}}
onMouseDown={(e) => {
if (e.button === 1) {
e.preventDefault()
}
}}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose()
}
}}
>
<Globe
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
<span className="truncate max-w-[180px] mr-1.5">{getBrowserTabLabel(tab)}</span>
{tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && (
<span className="mr-1.5 size-1.5 rounded-full bg-sky-500/80 shrink-0" />
)}
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
sideOffset={0}
align="start"
>
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
Close Tabs To The Right
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => void window.api.shell.openUrl(openInBrowserUrl)}
disabled={!isHttpUrl}
>
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
Open In Browser
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
+90 -15
View File
@@ -8,14 +8,30 @@ import {
type DragEndEvent
} from '@dnd-kit/core'
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { Plus } from 'lucide-react'
import type { TerminalTab } from '../../../../shared/types'
import { Globe, Plus, TerminalSquare } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
WorkspaceVisibleTabType
} from '../../../../shared/types'
import { useAppStore } from '../../store'
import { buildStatusMap } from '../right-sidebar/status-display'
import type { OpenFile } from '../../store/slices/editor'
import SortableTab from './SortableTab'
import EditorFileTab from './EditorFileTab'
import BrowserTab from './BrowserTab'
import { reconcileTabOrder } from './reconcile-order'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
const isMac = navigator.userAgent.includes('Mac')
const NEW_TERMINAL_SHORTCUT = isMac ? '⌘T' : 'Ctrl+T'
const NEW_BROWSER_SHORTCUT = isMac ? '⌘⇧B' : 'Ctrl+Shift+B'
type TabBarProps = {
tabs: TerminalTab[]
@@ -27,15 +43,20 @@ type TabBarProps = {
onCloseOthers: (tabId: string) => void
onCloseToRight: (tabId: string) => void
onReorder: (worktreeId: string, order: string[]) => void
onNewTab: () => void
onNewTerminalTab: () => void
onNewBrowserTab: () => void
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
editorFiles?: OpenFile[]
browserTabs?: BrowserTabState[]
activeFileId?: string | null
activeTabType?: 'terminal' | 'editor'
activeBrowserTabId?: string | null
activeTabType?: WorkspaceVisibleTabType
onActivateFile?: (fileId: string) => void
onCloseFile?: (fileId: string) => void
onActivateBrowserTab?: (tabId: string) => void
onCloseBrowserTab?: (tabId: string) => void
onCloseAllFiles?: () => void
onPinFile?: (fileId: string) => void
tabBarOrder?: string[]
@@ -44,6 +65,7 @@ type TabBarProps = {
type TabItem =
| { type: 'terminal'; id: string; data: TerminalTab }
| { type: 'editor'; id: string; data: OpenFile }
| { type: 'browser'; id: string; data: BrowserTabState }
export default function TabBar({
tabs,
@@ -55,15 +77,20 @@ export default function TabBar({
onCloseOthers,
onCloseToRight,
onReorder,
onNewTab,
onNewTerminalTab,
onNewBrowserTab,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand,
editorFiles,
browserTabs,
activeFileId,
activeBrowserTabId,
activeTabType,
onActivateFile,
onCloseFile,
onActivateBrowserTab,
onCloseBrowserTab,
onCloseAllFiles,
onPinFile,
tabBarOrder
@@ -82,13 +109,18 @@ export default function TabBar({
const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs])
const editorMap = useMemo(() => new Map((editorFiles ?? []).map((f) => [f.id, f])), [editorFiles])
const browserMap = useMemo(
() => new Map((browserTabs ?? []).map((t) => [t.id, t])),
[browserTabs]
)
const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs])
const editorFileIds = useMemo(() => editorFiles?.map((f) => f.id) ?? [], [editorFiles])
const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs])
// Build the unified ordered list, reconciling stored order with current items
const orderedItems = useMemo(() => {
const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds)
const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds, browserTabIds)
const items: TabItem[] = []
for (const id of ids) {
const terminal = terminalMap.get(id)
@@ -99,10 +131,15 @@ export default function TabBar({
const file = editorMap.get(id)
if (file) {
items.push({ type: 'editor', id, data: file })
continue
}
const browserTab = browserMap.get(id)
if (browserTab) {
items.push({ type: 'browser', id, data: browserTab })
}
}
return items
}, [tabBarOrder, terminalIds, editorFileIds, terminalMap, editorMap])
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
@@ -182,6 +219,19 @@ export default function TabBar({
/>
)
}
if (item.type === 'browser') {
return (
<BrowserTab
key={item.id}
tab={item.data}
isActive={activeTabType === 'browser' && activeBrowserTabId === item.id}
hasTabsToRight={index < orderedItems.length - 1}
onActivate={() => onActivateBrowserTab?.(item.id)}
onClose={() => onCloseBrowserTab?.(item.id)}
onCloseToRight={() => onCloseToRight(item.id)}
/>
)
}
return (
<EditorFileTab
key={item.id}
@@ -200,14 +250,39 @@ export default function TabBar({
</div>
</SortableContext>
</DndContext>
<button
className="flex items-center justify-center w-7 h-7 my-auto mx-1 shrink-0 rounded text-muted-foreground hover:text-foreground hover:bg-accent/50"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
onClick={onNewTab}
title="New terminal (Cmd+T)"
>
<Plus className="w-3.5 h-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="mx-1 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
title="New tab"
>
<Plus className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={6}
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
>
<DropdownMenuItem
onSelect={onNewTerminalTab}
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
>
<TerminalSquare className="size-4 text-muted-foreground" />
New Terminal
<DropdownMenuShortcut>{NEW_TERMINAL_SHORTCUT}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onNewBrowserTab}
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
>
<Globe className="size-4 text-muted-foreground" />
New Browser Tab
<DropdownMenuShortcut>{NEW_BROWSER_SHORTCUT}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -6,12 +6,13 @@
export function reconcileTabOrder(
storedOrder: string[] | undefined,
terminalIds: string[],
editorIds: string[]
editorIds: string[],
browserIds: string[] = []
): string[] {
const validIds = new Set([...terminalIds, ...editorIds])
const validIds = new Set([...terminalIds, ...editorIds, ...browserIds])
const result: string[] = (storedOrder ?? []).filter((id) => validIds.has(id))
const inResult = new Set(result)
for (const id of [...terminalIds, ...editorIds]) {
for (const id of [...terminalIds, ...editorIds, ...browserIds]) {
if (!inResult.has(id)) {
result.push(id)
inResult.add(id)
@@ -173,6 +173,15 @@ export function handleOscLink(
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
const store = useAppStore.getState()
// Why: when the user opts into Orca's browser tabs, terminal links should
// stay worktree-scoped instead of escaping to the system browser. We still
// fall back externally when the setting is off or no worktree owns the pane.
if (store.settings?.openLinksInApp && deps.worktreeId) {
store.setActiveWorktree(deps.worktreeId)
store.createBrowserTab(deps.worktreeId, parsed.toString())
return
}
void window.api.shell.openUrl(parsed.toString())
return
}
@@ -29,7 +29,8 @@ type TerminalShellProps = {
onCloseOthers: (tabId: string) => void
onCloseTabsToRight: (tabId: string) => void
onReorderTabs: (worktreeId: string, tabIds: string[]) => void
onNewTab: () => void
onNewTerminalTab: () => void
onNewBrowserTab: () => void
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
@@ -64,7 +65,8 @@ export function TerminalShell({
onCloseOthers,
onCloseTabsToRight,
onReorderTabs,
onNewTab,
onNewTerminalTab,
onNewBrowserTab,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand,
@@ -101,7 +103,8 @@ export function TerminalShell({
onCloseOthers={onCloseOthers}
onCloseToRight={onCloseTabsToRight}
onReorder={onReorderTabs}
onNewTab={onNewTab}
onNewTerminalTab={onNewTerminalTab}
onNewBrowserTab={onNewBrowserTab}
onSetCustomTitle={onSetCustomTitle}
onSetTabColor={onSetTabColor}
expandedPaneByTabId={expandedPaneByTabId}
+12 -1
View File
@@ -12,7 +12,7 @@ const ZOOM_STEP = 0.5
export function resolveZoomTarget(args: {
activeView: 'terminal' | 'settings'
activeTabType: 'terminal' | 'editor'
activeTabType: 'terminal' | 'editor' | 'browser'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {
const { activeView, activeTabType, activeElement } = args
@@ -119,6 +119,17 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.browser.onGuestLoadFailed(({ browserTabId, loadError }) => {
useAppStore.getState().updateBrowserTabPageState(browserTabId, {
loading: false,
loadError,
canGoBack: false,
canGoForward: false
})
})
)
// Zoom handling for menu accelerators and keyboard fallback paths.
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
+21
View File
@@ -0,0 +1,21 @@
declare module 'mermaid' {
type MermaidTheme = 'default' | 'dark'
type MermaidInitializeOptions = {
startOnLoad?: boolean
theme?: MermaidTheme
}
type MermaidRenderResult = {
svg: string
bindFunctions?: (element: Element) => void
}
type MermaidApi = {
initialize: (options: MermaidInitializeOptions) => void
render: (id: string, text: string) => Promise<MermaidRenderResult>
}
const mermaid: MermaidApi
export default mermaid
}
+3 -1
View File
@@ -10,6 +10,7 @@ import { createGitHubSlice } from './slices/github'
import { createEditorSlice } from './slices/editor'
import { createStatsSlice } from './slices/stats'
import { createClaudeUsageSlice } from './slices/claude-usage'
import { createBrowserSlice } from './slices/browser'
export const useAppStore = create<AppState>()((...a) => ({
...createRepoSlice(...a),
@@ -21,7 +22,8 @@ export const useAppStore = create<AppState>()((...a) => ({
...createGitHubSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a)
...createClaudeUsageSlice(...a),
...createBrowserSlice(...a)
}))
export type { AppState } from './types'
+380
View File
@@ -0,0 +1,380 @@
/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { BrowserLoadError, BrowserTab, WorkspaceSessionState } from '../../../../shared/types'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
type CreateBrowserTabOptions = {
activate?: boolean
title?: string
}
type BrowserTabPageState = {
title?: string
loading?: boolean
faviconUrl?: string | null
canGoBack?: boolean
canGoForward?: boolean
loadError?: BrowserLoadError | null
}
export type BrowserSlice = {
browserTabsByWorktree: Record<string, BrowserTab[]>
activeBrowserTabId: string | null
activeBrowserTabIdByWorktree: Record<string, string | null>
createBrowserTab: (
worktreeId: string,
url: string,
options?: CreateBrowserTabOptions
) => BrowserTab
closeBrowserTab: (tabId: string) => void
setActiveBrowserTab: (tabId: string) => void
updateBrowserTabPageState: (tabId: string, updates: BrowserTabPageState) => void
setBrowserTabUrl: (tabId: string, url: string) => void
hydrateBrowserSession: (session: WorkspaceSessionState) => void
}
function normalizeUrl(url: string): string {
const trimmed = url.trim()
if (trimmed.length === 0) {
return 'about:blank'
}
return trimmed
}
function getFallbackTabTypeForWorktree(
worktreeId: string,
openFiles: AppState['openFiles'],
terminalTabsByWorktree: AppState['tabsByWorktree'],
browserTabsByWorktree?: AppState['browserTabsByWorktree']
): AppState['activeTabType'] {
if (openFiles.some((file) => file.worktreeId === worktreeId)) {
return 'editor'
}
if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) {
return 'browser'
}
if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) {
return 'terminal'
}
return 'terminal'
}
export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> = (set) => ({
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
createBrowserTab: (worktreeId, url, options) => {
const id = globalThis.crypto.randomUUID()
const now = Date.now()
const normalizedUrl = normalizeUrl(url)
let browserTab!: BrowserTab
set((s) => {
const existingTabs = s.browserTabsByWorktree[worktreeId] ?? []
browserTab = {
id,
worktreeId,
url: normalizedUrl,
title: options?.title ?? normalizedUrl,
// Why: blank tabs mount a parked/inert guest surface first. Marking
// them as loading at creation time makes every about:blank tab flash
// the browser loading dot even when no navigation was requested.
// Real navigations still flip loading via the browser pane events.
loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: now
}
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const editorIds = s.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((f) => f.id)
const browserIds = existingTabs.map((tab) => tab.id)
const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds])
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
const inBase = new Set(base)
for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) {
if (!inBase.has(entryId)) {
base.push(entryId)
inBase.add(entryId)
}
}
base.push(id)
return base
})()
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: [...existingTabs, browserTab]
},
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: nextTabBarOrder
},
activeBrowserTabId: shouldActivate ? id : s.activeBrowserTabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: shouldActivate ? id : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
},
// Why: browser tabs live in the same visual strip as terminals and editors.
// Creating one should immediately select the browser surface for that
// worktree, but only the active worktree is allowed to drive Orca's
// global visible surface. Background worktrees keep their per-worktree
// browser selection without stealing the foreground pane.
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
activeTabTypeByWorktree: shouldActivate
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
: s.activeTabTypeByWorktree
}
})
return browserTab
},
closeBrowserTab: (tabId) =>
set((s) => {
let owningWorktreeId: string | null = null
const nextBrowserTabsByWorktree: Record<string, BrowserTab[]> = {}
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const filtered = tabs.filter((tab) => tab.id !== tabId)
if (filtered.length !== tabs.length) {
owningWorktreeId = worktreeId
}
if (filtered.length > 0) {
nextBrowserTabsByWorktree[worktreeId] = filtered
}
}
if (!owningWorktreeId) {
return s
}
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? []
if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) {
nextActiveBrowserTabIdByWorktree[owningWorktreeId] = remainingBrowserTabs[0]?.id ?? null
}
const nextTabBarOrder = {
...s.tabBarOrderByWorktree,
[owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter(
(entryId) => entryId !== tabId
)
}
const isActiveTabInOwningWorktree =
s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
let nextActiveTabType = s.activeTabType
if (remainingBrowserTabs.length === 0) {
const fallbackTabType = getFallbackTabTypeForWorktree(
owningWorktreeId,
s.openFiles,
s.tabsByWorktree
)
nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType
if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') {
// Why: the per-worktree restore map and the global active surface must
// stay in lockstep. Leaving activeTabType at "browser" after the last
// browser tab closes makes the workspace point at a surface that no
// longer exists, which later renders as a blank body until another
// caller repairs state opportunistically.
nextActiveTabType = fallbackTabType
}
}
return {
browserTabsByWorktree: nextBrowserTabsByWorktree,
activeBrowserTabId:
s.activeBrowserTabId === tabId
? (remainingBrowserTabs[0]?.id ?? null)
: s.activeBrowserTabId,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
tabBarOrderByWorktree: nextTabBarOrder,
activeTabType: nextActiveTabType,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree
}
}),
setActiveBrowserTab: (tabId) =>
set((s) => {
const browserTab = Object.values(s.browserTabsByWorktree)
.flat()
.find((tab) => tab.id === tabId)
if (!browserTab) {
return s
}
return {
activeBrowserTabId: tabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[browserTab.worktreeId]: tabId
},
activeTabType: 'browser',
activeTabTypeByWorktree: {
...s.activeTabTypeByWorktree,
[browserTab.worktreeId]: 'browser'
}
}
}),
updateBrowserTabPageState: (tabId, updates) =>
set((s) => ({
browserTabsByWorktree: Object.fromEntries(
Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) =>
tab.id === tabId
? {
...tab,
title: updates.title ?? tab.title,
loading: updates.loading ?? tab.loading,
faviconUrl:
updates.faviconUrl === undefined ? tab.faviconUrl : updates.faviconUrl,
canGoBack: updates.canGoBack ?? tab.canGoBack,
canGoForward: updates.canGoForward ?? tab.canGoForward,
loadError: updates.loadError === undefined ? tab.loadError : updates.loadError
}
: tab
)
])
)
})),
setBrowserTabUrl: (tabId, url) =>
set((s) => ({
browserTabsByWorktree: Object.fromEntries(
Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) =>
tab.id === tabId
? {
...tab,
url: normalizeUrl(url),
loading: true,
loadError: null
}
: tab
)
])
)
})),
hydrateBrowserSession: (session) =>
set((s) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((worktree) => worktree.id)
)
const browserTabsByWorktree: Record<string, BrowserTab[]> = Object.fromEntries(
Object.entries(persistedTabsByWorktree)
.filter(([worktreeId]) => validWorktreeIds.has(worktreeId))
.map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => ({
...tab,
url: normalizeUrl(tab.url),
loading: false,
loadError: tab.loadError ?? null
}))
])
.filter(([, tabs]) => (tabs as BrowserTab[]).length > 0)
)
const validBrowserTabIds = new Set(
Object.values(browserTabsByWorktree)
.flat()
.map((tab) => tab.id)
)
const activeBrowserTabIdByWorktree: Record<string, string | null> = {}
for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) {
const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId]
activeBrowserTabIdByWorktree[worktreeId] =
persistedTabId && validBrowserTabIds.has(persistedTabId)
? persistedTabId
: (tabs[0]?.id ?? null)
}
const activeWorktreeId = s.activeWorktreeId
const activeBrowserTabId =
activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId]
? activeBrowserTabIdByWorktree[activeWorktreeId]
: null
// Why: hydrateEditorSession may have returned early (no editor files),
// leaving activeTabTypeByWorktree as {}. We must merge in the 'browser'
// entries from the persisted session, otherwise setActiveWorktree will
// default to 'terminal' when switching to a worktree whose last-active
// tab was a browser tab — causing a blank screen.
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
for (const worktreeId of validWorktreeIds) {
const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0
if (
persistedActiveTabTypeByWorktree[worktreeId] === 'browser' &&
hasBrowserTabs &&
!nextActiveTabTypeByWorktree[worktreeId]
) {
// Why: browser hydration runs after editor hydration and owns only the
// browser-visible restore path. Keep browser tab restores intact when
// the persisted session still has a valid browser tab for that worktree.
nextActiveTabTypeByWorktree[worktreeId] = 'browser'
continue
}
if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) {
// Why: older/broken sessions can retain "browser" as the remembered
// surface for a worktree after its browser tabs were closed. Leaving
// that stale marker behind makes Terminal render the browser surface
// with no matching tab, which looks like a blank app.
nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree(
worktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
}
}
const activeTabType = (() => {
if (!activeWorktreeId) {
return s.activeTabType
}
const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId]
if (restoredTabType === 'browser' && activeBrowserTabId) {
return 'browser'
}
if (
restoredTabType === 'editor' &&
s.openFiles.some((file) => file.worktreeId === activeWorktreeId)
) {
return 'editor'
}
return getFallbackTabTypeForWorktree(
activeWorktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
})()
return {
browserTabsByWorktree,
activeBrowserTabIdByWorktree,
activeBrowserTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType
}
})
})
@@ -10,6 +10,9 @@ function createEditorStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
@@ -216,6 +219,80 @@ describe('createEditorSlice editor drafts', () => {
expect(store.getState().editorDrafts).toEqual({})
})
it('falls back to a browser tab when closing the last editor in the active worktree', () => {
const store = createEditorStore()
store.setState({
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-1',
worktreeId: 'wt-1',
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' }
})
store.getState().openFile({
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().closeFile('/repo/src/file.ts')
expect(store.getState().activeTabType).toBe('browser')
expect(store.getState().activeBrowserTabId).toBe('browser-1')
})
it('falls back to a browser tab when closing all editors in the active worktree', () => {
const store = createEditorStore()
store.setState({
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-1',
worktreeId: 'wt-1',
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' }
})
store.getState().openFile({
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().closeAllFiles()
expect(store.getState().activeTabType).toBe('browser')
expect(store.getState().activeBrowserTabId).toBe('browser-1')
})
})
describe('createEditorSlice conflict status reconciliation', () => {
+53 -13
View File
@@ -12,7 +12,8 @@ import type {
GitStatusEntry,
GitStatusResult,
SearchResult,
WorkspaceSessionState
WorkspaceSessionState,
WorkspaceVisibleTabType
} from '../../../../shared/types'
export type DiffSource =
@@ -141,9 +142,9 @@ export type EditorSlice = {
openFiles: OpenFile[]
activeFileId: string | null
activeFileIdByWorktree: Record<string, string | null> // worktreeId -> last active file
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'> // worktreeId -> last active tab type
activeTabType: 'terminal' | 'editor'
setActiveTabType: (type: 'terminal' | 'editor') => void
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType> // worktreeId -> last active tab type
activeTabType: WorkspaceVisibleTabType
setActiveTabType: (type: WorkspaceVisibleTabType) => void
openFile: (file: Omit<OpenFile, 'id' | 'isDirty'>, options?: { preview?: boolean }) => void
pinFile: (fileId: string) => void
closeFile: (fileId: string) => void
@@ -526,21 +527,42 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}
// When last editor file for current worktree is closed, switch back to terminal
// Why: editor tabs share a mixed tab strip with browser tabs. Closing the
// last editor in a worktree should reveal an available browser tab before
// falling all the way back to a terminal surface.
const activeWorktreeId = s.activeWorktreeId
const remainingForWorktree = activeWorktreeId
? newFiles.filter((f) => f.worktreeId === activeWorktreeId)
: newFiles
const newActiveTabType = remainingForWorktree.length === 0 ? 'terminal' : s.activeTabType
const browserTabsForWorktree = activeWorktreeId
? (s.browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const fallbackBrowserTabId =
activeWorktreeId && browserTabsForWorktree.length > 0
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
browserTabsForWorktree[0]?.id ??
null)
: s.activeBrowserTabId
const newActiveTabType =
remainingForWorktree.length > 0
? s.activeTabType
: browserTabsForWorktree.length > 0
? 'browser'
: 'terminal'
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
if (activeWorktreeId && remainingForWorktree.length === 0) {
newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal'
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
}
return {
openFiles: newFiles,
editorDrafts: newEditorDrafts,
activeFileId: newActiveId,
activeBrowserTabId:
activeWorktreeId && remainingForWorktree.length === 0
? fallbackBrowserTabId
: s.activeBrowserTabId,
activeTabType: newActiveTabType,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
@@ -574,12 +596,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
delete newActiveFileIdByWorktree[activeWorktreeId]
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal'
const browserTabsForWorktree = s.browserTabsByWorktree[activeWorktreeId] ?? []
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
return {
openFiles: newFiles,
editorDrafts: newEditorDrafts,
activeFileId: null,
activeTabType: 'terminal',
activeBrowserTabId:
browserTabsForWorktree.length > 0
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
browserTabsForWorktree[0]?.id ??
null)
: s.activeBrowserTabId,
activeTabType: browserTabsForWorktree.length > 0 ? 'browser' : 'terminal',
markdownViewMode: newMarkdownViewMode,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
@@ -1296,7 +1326,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// The file may have been removed due to worktree validation or the
// persisted data may reference a stale path.
const activeFileExists = activeFileId ? openFiles.some((f) => f.id === activeFileId) : false
const activeTabType =
const activeTabType: WorkspaceVisibleTabType =
activeWorktreeId && persistedActiveTabTypeByWorktree[activeWorktreeId]
? persistedActiveTabTypeByWorktree[activeWorktreeId]
: 'terminal'
@@ -1309,9 +1339,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
)
)
const filteredActiveTabTypeByWorktree = Object.fromEntries(
Object.entries(persistedActiveTabTypeByWorktree).filter(([wId]) =>
validWorktreeIds.has(wId)
)
Object.entries(persistedActiveTabTypeByWorktree).filter(([wId, tabType]) => {
if (!validWorktreeIds.has(wId)) {
return false
}
if (tabType !== 'editor') {
return true
}
// Why: a persisted "editor" surface only makes sense if that
// worktree still restored a concrete active editor file. Otherwise we
// preserve a stale last-active marker that conflicts with browser or
// terminal restore logic for the same worktree.
return Boolean(filteredActiveFileIdByWorktree[wId])
})
)
return {
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock sonner (imported by repos.ts)
@@ -315,4 +316,160 @@ describe('setActiveWorktree', () => {
expect(worktree.sortOrder).toBe(123)
expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled()
})
it('falls back to the worktree browser tab when the restored editor id belongs to a different worktree', () => {
const store = createTestStore()
const wt1 = 'repo1::/path/wt1'
const wt2 = 'repo1::/path/wt2'
const otherFileId = '/path/wt2/file.ts'
const browserTabId = 'browser-1'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: wt2, repoId: 'repo1', path: '/path/wt2' })
]
},
openFiles: [makeOpenFile({ id: otherFileId, worktreeId: wt2 })],
activeFileIdByWorktree: { [wt1]: otherFileId },
browserTabsByWorktree: {
[wt1]: [
{
id: browserTabId,
worktreeId: wt1,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { [wt1]: browserTabId },
activeTabTypeByWorktree: { [wt1]: 'editor' }
})
store.getState().setActiveWorktree(wt1)
const s = store.getState()
expect(s.activeWorktreeId).toBe(wt1)
expect(s.activeBrowserTabId).toBe(browserTabId)
expect(s.activeTabType).toBe('browser')
expect(s.activeFileId).toBeNull()
})
it('clears stale background browser tab type when closing the last browser tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: null,
tabsByWorktree: {
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
},
browserTabsByWorktree: {
[wt]: [
{
id: 'browser-1',
worktreeId: wt,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { [wt]: 'browser-1' },
activeTabTypeByWorktree: { [wt]: 'browser' }
})
store.getState().closeBrowserTab('browser-1')
expect(store.getState().activeTabTypeByWorktree[wt]).toBe('terminal')
expect(store.getState().activeBrowserTabIdByWorktree[wt]).toBeNull()
})
it('falls back to editor globally when closing the last active browser tab in a worktree with files', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const fileId = '/path/wt1/src/index.ts'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt,
activeTabType: 'browser',
openFiles: [makeOpenFile({ id: fileId, worktreeId: wt, filePath: fileId })],
activeFileId: fileId,
activeFileIdByWorktree: { [wt]: fileId },
activeTabTypeByWorktree: { [wt]: 'browser' },
browserTabsByWorktree: {
[wt]: [
{
id: 'browser-1',
worktreeId: wt,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabId: 'browser-1',
activeBrowserTabIdByWorktree: { [wt]: 'browser-1' }
})
store.getState().closeBrowserTab('browser-1')
const s = store.getState()
expect(s.activeTabType).toBe('editor')
expect(s.activeTabTypeByWorktree[wt]).toBe('editor')
expect(s.activeFileId).toBe(fileId)
})
it('does not switch the global surface when creating a browser tab for a background worktree', () => {
const store = createTestStore()
const activeWt = 'repo1::/path/wt1'
const backgroundWt = 'repo1::/path/wt2'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({ id: activeWt, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: backgroundWt, repoId: 'repo1', path: '/path/wt2' })
]
},
activeWorktreeId: activeWt,
activeTabType: 'terminal',
tabsByWorktree: {
[activeWt]: [makeTab({ id: 'terminal-1', worktreeId: activeWt })],
[backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })]
}
})
const browserTab = store
.getState()
.createBrowserTab(backgroundWt, 'https://example.com', { activate: true })
const s = store.getState()
expect(s.activeTabType).toBe('terminal')
expect(s.activeTabTypeByWorktree[backgroundWt]).toBe('browser')
expect(s.activeBrowserTabIdByWorktree[backgroundWt]).toBe(browserTab.id)
})
})
@@ -2,7 +2,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, TerminalTab, TerminalLayoutSnapshot } from '../../../../shared/types'
import type {
BrowserTab,
TerminalLayoutSnapshot,
TerminalTab,
Worktree
} from '../../../../shared/types'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@@ -73,6 +78,7 @@ import { createGitHubSlice } from './github'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
import { createBrowserSlice } from './browser'
function createTestStore() {
return create<AppState>()((...a) => ({
@@ -85,7 +91,8 @@ function createTestStore() {
...createGitHubSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a)
...createClaudeUsageSlice(...a),
...createBrowserSlice(...a)
}))
}
@@ -128,6 +135,21 @@ function makeLayout(): TerminalLayoutSnapshot {
return { root: null, activeLeafId: null, expandedLeafId: null }
}
function makeBrowserTab(
overrides: Partial<BrowserTab> & { id: string; worktreeId: string; url: string }
): BrowserTab {
return {
title: overrides.url,
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: Date.now(),
...overrides
}
}
// ─── Tests ────────────────────────────────────────────────────────────
describe('removeRepo cascade', () => {
@@ -284,6 +306,204 @@ describe('hydrateWorkspaceSession', () => {
})
})
describe('hydrateBrowserSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('falls back to the first valid browser tab when the persisted active browser tab is missing', () => {
const store = createTestStore()
const validWt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: validWt
})
store.getState().hydrateBrowserSession({
activeRepoId: 'repo1',
activeWorktreeId: validWt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[validWt]: [
makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' }),
makeBrowserTab({ id: 'browser-2', worktreeId: validWt, url: 'https://openai.com' })
]
},
activeBrowserTabIdByWorktree: {
[validWt]: 'missing-browser-id'
},
activeTabTypeByWorktree: {
[validWt]: 'browser'
}
})
const s = store.getState()
expect(s.browserTabsByWorktree[validWt]).toHaveLength(2)
expect(s.activeBrowserTabIdByWorktree[validWt]).toBe('browser-1')
expect(s.activeBrowserTabId).toBe('browser-1')
})
it('restores activeTabTypeByWorktree for browser worktrees when hydrateEditorSession was a no-op', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt,
// Simulate hydrateEditorSession returning {} (no editor files) —
// activeTabTypeByWorktree stays at the initial empty object
activeTabTypeByWorktree: {}
})
store.getState().hydrateBrowserSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[wt]: [makeBrowserTab({ id: 'browser-1', worktreeId: wt, url: 'https://example.com' })]
},
activeBrowserTabIdByWorktree: { [wt]: 'browser-1' },
activeTabTypeByWorktree: { [wt]: 'browser' }
})
const s = store.getState()
// hydrateBrowserSession must merge 'browser' entries into activeTabTypeByWorktree
// so setActiveWorktree doesn't default to 'terminal' and cause a blank screen
expect(s.activeTabTypeByWorktree[wt]).toBe('browser')
expect(s.activeTabType).toBe('browser')
expect(s.activeBrowserTabId).toBe('browser-1')
})
it('does not overwrite existing activeTabTypeByWorktree entries from hydrateEditorSession', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt,
// Simulate hydrateEditorSession having already set this to 'editor'
activeTabTypeByWorktree: { [wt]: 'editor' }
})
store.getState().hydrateBrowserSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[wt]: [makeBrowserTab({ id: 'browser-1', worktreeId: wt, url: 'https://example.com' })]
},
activeBrowserTabIdByWorktree: { [wt]: 'browser-1' },
activeTabTypeByWorktree: { [wt]: 'browser' }
})
const s = store.getState()
// The existing 'editor' entry set by hydrateEditorSession must not be overwritten
expect(s.activeTabTypeByWorktree[wt]).toBe('editor')
})
it('drops browser tabs for invalid worktrees', () => {
const store = createTestStore()
const validWt = 'repo1::/path/wt1'
const invalidWt = 'repo1::/path/gone'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: validWt
})
store.getState().hydrateBrowserSession({
activeRepoId: 'repo1',
activeWorktreeId: validWt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[validWt]: [
makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' })
],
[invalidWt]: [
makeBrowserTab({ id: 'browser-bad', worktreeId: invalidWt, url: 'https://bad.invalid' })
]
},
activeBrowserTabIdByWorktree: {
[validWt]: 'browser-1',
[invalidWt]: 'browser-bad'
}
})
const s = store.getState()
expect(s.browserTabsByWorktree[validWt]).toHaveLength(1)
expect(s.browserTabsByWorktree[invalidWt]).toBeUndefined()
expect(s.activeBrowserTabIdByWorktree[invalidWt]).toBeUndefined()
})
it('normalizes stale browser tab-type restores when the worktree has no browser tabs', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt,
tabsByWorktree: {
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
},
activeTabTypeByWorktree: { [wt]: 'browser' },
activeTabType: 'browser'
})
store.getState().hydrateBrowserSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: 'terminal-1',
tabsByWorktree: {
[wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })]
},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeTabTypeByWorktree: { [wt]: 'browser' }
})
const s = store.getState()
expect(s.activeTabTypeByWorktree[wt]).toBe('terminal')
expect(s.activeTabType).toBe('terminal')
expect(s.activeBrowserTabIdByWorktree[wt]).toBeUndefined()
expect(s.activeBrowserTabId).toBeNull()
})
})
describe('terminal slice behaviors', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -726,6 +946,7 @@ describe('hydrateEditorSession', () => {
expect(s.openFiles).toHaveLength(1)
expect(s.activeFileId).toBeNull()
expect(s.activeTabType).toBe('terminal')
expect(s.activeTabTypeByWorktree[wt]).toBeUndefined()
})
it('filters out files for deleted worktrees', () => {
@@ -18,6 +18,7 @@ import { createGitHubSlice } from './github'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
import { createBrowserSlice } from './browser'
export const TEST_REPO = {
id: 'repo1',
@@ -38,7 +39,8 @@ export function createTestStore() {
...createGitHubSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a)
...createClaudeUsageSlice(...a),
...createBrowserSlice(...a)
}))
}
+3 -1
View File
@@ -73,6 +73,7 @@ import { createGitHubSlice } from './github'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createClaudeUsageSlice } from './claude-usage'
import { createBrowserSlice } from './browser'
const WT = 'repo1::/tmp/feature'
@@ -87,7 +88,8 @@ function createTestStore() {
...createGitHubSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
...createClaudeUsageSlice(...a)
...createClaudeUsageSlice(...a),
...createBrowserSlice(...a)
}))
}
+43 -8
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { Worktree } from '../../../../shared/types'
import type { Worktree, WorkspaceVisibleTabType } from '../../../../shared/types'
import {
findWorktreeById,
applyWorktreeUpdates,
@@ -124,14 +124,19 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
delete nextDeleteState[worktreeId]
// Clean up editor files belonging to this worktree
const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId)
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
delete nextBrowserTabsByWorktree[worktreeId]
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
delete nextActiveFileIdByWorktree[worktreeId]
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
delete nextActiveBrowserTabIdByWorktree[worktreeId]
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
delete nextActiveTabTypeByWorktree[worktreeId]
// If the active file belonged to the removed worktree, clear it
const activeFileCleared = s.activeFileId
? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId)
: false
const removedActiveWorktree = s.activeWorktreeId === worktreeId
return {
worktreesByRepo: next,
tabsByWorktree: nextTabs,
@@ -146,13 +151,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
delete nextSearch[worktreeId]
return nextSearch
})(),
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId,
activeWorktreeId: removedActiveWorktree ? null : s.activeWorktreeId,
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId,
openFiles: newOpenFiles,
browserTabsByWorktree: nextBrowserTabsByWorktree,
activeFileIdByWorktree: nextActiveFileIdByWorktree,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId,
activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType,
sortEpoch: s.sortEpoch + 1
}
})
@@ -290,22 +298,47 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// Restore per-worktree editor state
const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null
const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal'
// Verify the restored file still exists in openFiles
const fileStillOpen = restoredFileId
? s.openFiles.some((f) => f.id === restoredFileId)
? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId)
: false
const browserTabs = s.browserTabsByWorktree[worktreeId] ?? []
const browserTabStillOpen = restoredBrowserTabId
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
: false
// If restored file is gone, fall back to another open file for this worktree
let activeFileId: string | null
let activeTabType: 'terminal' | 'editor'
if (fileStillOpen) {
let activeBrowserTabId: string | null
let activeTabType: WorkspaceVisibleTabType
if (restoredTabType === 'browser' && browserTabStillOpen) {
activeFileId = fileStillOpen ? restoredFileId : null
activeBrowserTabId = restoredBrowserTabId
activeTabType = 'browser'
} else if (restoredTabType === 'editor' && fileStillOpen) {
activeFileId = restoredFileId
activeTabType = restoredTabType
activeBrowserTabId = browserTabStillOpen
? restoredBrowserTabId
: (browserTabs[0]?.id ?? null)
activeTabType = 'editor'
} else if (browserTabStillOpen) {
activeFileId = null
activeBrowserTabId = restoredBrowserTabId
activeTabType = 'browser'
} else if (fileStillOpen) {
activeFileId = restoredFileId
activeBrowserTabId = browserTabs[0]?.id ?? null
activeTabType = 'editor'
} else {
const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId)
const fallbackBrowserTab = browserTabs[0] ?? null
activeFileId = fallbackFile?.id ?? null
activeTabType = fallbackFile ? 'editor' : 'terminal'
activeBrowserTabId = browserTabStillOpen
? restoredBrowserTabId
: (fallbackBrowserTab?.id ?? null)
activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal'
}
// Why: restore the last-active terminal tab for this worktree so the
@@ -320,7 +353,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return {
activeWorktreeId: worktreeId,
activeFileId,
activeBrowserTabId,
activeTabType,
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType },
activeTabId,
worktreesByRepo: applyWorktreeUpdates(
s.worktreesByRepo,
+3 -1
View File
@@ -8,6 +8,7 @@ import type { GitHubSlice } from './slices/github'
import type { EditorSlice } from './slices/editor'
import type { StatsSlice } from './slices/stats'
import type { ClaudeUsageSlice } from './slices/claude-usage'
import type { BrowserSlice } from './slices/browser'
export type AppState = RepoSlice &
WorktreeSlice &
@@ -18,4 +19,5 @@ export type AppState = RepoSlice &
GitHubSlice &
EditorSlice &
StatsSlice &
ClaudeUsageSlice
ClaudeUsageSlice &
BrowserSlice
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { ORCA_BROWSER_BLANK_URL } from './constants'
import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl } from './browser-url'
describe('browser-url helpers', () => {
it('normalizes manual local-dev inputs to http', () => {
expect(normalizeBrowserNavigationUrl('localhost:3000')).toBe('http://localhost:3000/')
expect(normalizeBrowserNavigationUrl('127.0.0.1:5173')).toBe('http://127.0.0.1:5173/')
})
it('keeps normal web URLs and blank tabs in the allowed set', () => {
expect(normalizeBrowserNavigationUrl('https://example.com')).toBe('https://example.com/')
expect(normalizeBrowserNavigationUrl('')).toBe(ORCA_BROWSER_BLANK_URL)
expect(normalizeBrowserNavigationUrl('about:blank')).toBe(ORCA_BROWSER_BLANK_URL)
})
it('rejects non-web schemes for in-app navigation', () => {
expect(normalizeBrowserNavigationUrl('file:///etc/passwd')).toBeNull()
expect(normalizeBrowserNavigationUrl('javascript:alert(1)')).toBeNull()
expect(normalizeExternalBrowserUrl('about:blank')).toBeNull()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { ORCA_BROWSER_BLANK_URL } from './constants'
const LOCAL_ADDRESS_PATTERN =
/^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:\/.*)?$/i
export function normalizeBrowserNavigationUrl(rawUrl: string): string | null {
const trimmed = rawUrl.trim()
if (trimmed.length === 0 || trimmed === 'about:blank' || trimmed === ORCA_BROWSER_BLANK_URL) {
return ORCA_BROWSER_BLANK_URL
}
if (LOCAL_ADDRESS_PATTERN.test(trimmed)) {
try {
return new URL(`http://${trimmed}`).toString()
} catch {
return null
}
}
try {
const parsed = new URL(trimmed)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null
} catch {
try {
return new URL(`https://${trimmed}`).toString()
} catch {
return null
}
}
}
export function normalizeExternalBrowserUrl(rawUrl: string): string | null {
const normalized = normalizeBrowserNavigationUrl(rawUrl)
return normalized === ORCA_BROWSER_BLANK_URL ? null : normalized
}
+9
View File
@@ -10,6 +10,12 @@ import type {
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
export const SCHEMA_VERSION = 1
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// Why: blank browser tabs must start from an inert guest URL that does not
// navigate the privileged main window to about:blank. Renderer and main both
// need the exact same value so the attach policy can allow only this one safe
// data URL while still rejecting arbitrary renderer-provided data URLs.
export const ORCA_BROWSER_BLANK_URL = 'data:text/html,'
// Pick a default terminal font that is likely to exist on the current OS.
// buildFontFamily() adds the full cross-platform fallback chain, so this only
@@ -95,6 +101,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// focus-follows-mouse never happens unexpectedly.
terminalFocusFollowsMouse: false,
terminalScrollbackBytes: 10_000_000,
openLinksInApp: false,
rightSidebarOpenByDefault: true,
notifications: getDefaultNotificationSettings(),
diffDefaultView: 'inline',
@@ -152,6 +159,8 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {},
browserTabsByWorktree: {},
activeBrowserTabIdByWorktree: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {}
}
+32 -3
View File
@@ -56,7 +56,9 @@ export type WorktreeMeta = {
}
// ─── Unified Tab ────────────────────────────────────────────────────
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review'
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser'
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser'
export type Tab = {
id: string // UUID for terminals, filePath for editors (preserves current convention)
@@ -93,6 +95,25 @@ export type TerminalTab = {
generation?: number
}
export type BrowserLoadError = {
code: number
description: string
validatedUrl: string
}
export type BrowserTab = {
id: string
worktreeId: string
url: string
title: string
loading: boolean
faviconUrl: string | null
canGoBack: boolean
canGoForward: boolean
loadError: BrowserLoadError | null
createdAt: number
}
export type TerminalPaneSplitDirection = 'vertical' | 'horizontal'
export type TerminalPaneLayoutNode =
@@ -147,8 +168,12 @@ export type WorkspaceSessionState = {
openFilesByWorktree?: Record<string, PersistedOpenFile[]>
/** Per-worktree active editor file ID (filePath) at shutdown. */
activeFileIdByWorktree?: Record<string, string | null>
/** Per-worktree active tab type (terminal vs editor) at shutdown. */
activeTabTypeByWorktree?: Record<string, 'terminal' | 'editor'>
/** Persisted browser tabs, keyed by worktree ID. */
browserTabsByWorktree?: Record<string, BrowserTab[]>
/** Per-worktree active browser tab ID at shutdown. */
activeBrowserTabIdByWorktree?: Record<string, string | null>
/** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */
activeTabTypeByWorktree?: Record<string, WorkspaceVisibleTabType>
/** Per-worktree last-active terminal tab ID at shutdown. */
activeTabIdByWorktree?: Record<string, string | null>
/** Unified tab model — present when saved by a build that includes TabsSlice.
@@ -314,6 +339,10 @@ export type GlobalSettings = {
terminalDividerThicknessPx: number
terminalFocusFollowsMouse: boolean
terminalScrollbackBytes: number
/** Why: opening arbitrary links inside Orca uses an isolated guest browser surface.
* The setting stays opt-in so existing workflows continue to use the system browser
* until the user explicitly wants worktree-scoped in-app browsing. */
openLinksInApp: boolean
rightSidebarOpenByDefault: boolean
diffDefaultView: 'inline' | 'side-by-side'
notifications: NotificationSettings