feat(browser): new tab page with a workspace card and suggested sites (#31656)

This commit is contained in:
Kelvin Amoaba
2026-10-21 23:50:12 +00:00
committed by GitHub
parent 54ef452d71
commit e34b3709b3
46 changed files with 1526 additions and 118 deletions
+25
View File
@@ -5,6 +5,8 @@ import type {
WorkspacePortAdvertisedUrlChangedEvent,
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortPageProbeRequest,
WorkspacePortPageProbeResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
@@ -13,6 +15,7 @@ import {
killWorkspacePort,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
import { probeWorkspacePortPage } from '../ports/workspace-port-page-probe'
type WorkspacePortHandlersOptions = {
advertisedUrlEvents?: Pick<AdvertisedUrlWatcher, 'onDidChange'>
@@ -40,6 +43,7 @@ export function registerWorkspacePortHandlers(
ipcMain.removeHandler('workspacePorts:scan')
ipcMain.removeHandler('workspacePorts:kill')
ipcMain.removeHandler('workspacePorts:probePage')
ipcMain.handle(
'workspacePorts:scan',
(_event, rawArgs?: unknown): Promise<WorkspacePortScanResult> => {
@@ -76,6 +80,14 @@ export function registerWorkspacePortHandlers(
return killWorkspacePort(worktrees, args)
}
)
ipcMain.handle(
'workspacePorts:probePage',
async (_event, rawArgs?: unknown): Promise<WorkspacePortPageProbeResult> => {
const args = parseProbePageRequest(rawArgs)
return args ? probeWorkspacePortPage(args) : { kind: 'not-page' }
}
)
}
function broadcastWorkspacePortAdvertisedUrlChanged(
@@ -118,3 +130,16 @@ function parseKillRequest(value: unknown): WorkspacePortKillRequest | null {
port
}
}
function parseProbePageRequest(value: unknown): WorkspacePortPageProbeRequest | null {
if (!value || typeof value !== 'object') {
return null
}
const host = 'host' in value ? value.host : undefined
const port = 'port' in value ? value.port : undefined
if (typeof host !== 'string' || typeof port !== 'number' || !Number.isSafeInteger(port)) {
return null
}
const protocol = 'protocol' in value && value.protocol === 'https' ? 'https' : 'http'
return { host, port, protocol }
}
@@ -0,0 +1,89 @@
import http from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { probeWorkspacePortPage } from './workspace-port-page-probe'
const ICON = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>')
const servers: http.Server[] = []
async function serve(handler: http.RequestListener): Promise<number> {
const server = http.createServer(handler)
servers.push(server)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Expected a TCP test server')
}
return address.port
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))
)
})
describe('probeWorkspacePortPage', () => {
it('reads the title and linked icon of a page a dev server serves', async () => {
const port = await serve((request, response) => {
if (request.url === '/assets/icon.svg') {
response.writeHead(200, { 'content-type': 'image/svg+xml' }).end(ICON)
return
}
response
.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
.end(
'<html><head><title>\n Vite &amp; React </title><link rel="shortcut icon" href="/assets/icon.svg"></head></html>'
)
})
await expect(
probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' })
).resolves.toEqual({
kind: 'page',
title: 'Vite & React',
faviconDataUrl: `data:image/svg+xml;base64,${ICON.toString('base64')}`
})
})
it('follows a same-origin redirect to the page', async () => {
const port = await serve((request, response) => {
if (request.url === '/') {
response.writeHead(302, { location: '/login' }).end()
return
}
response.writeHead(200, { 'content-type': 'text/html' }).end('<title>Sign in</title>')
})
await expect(
probeWorkspacePortPage({ host: '127.0.0.1', port, protocol: 'http' })
).resolves.toMatchObject({ kind: 'page', title: 'Sign in', faviconDataUrl: null })
})
it('leaves out APIs and error pages', async () => {
const api = await serve((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}')
})
const notFound = await serve((_request, response) => {
response.writeHead(404, { 'content-type': 'text/html' }).end('Cannot GET /')
})
for (const port of [api, notFound]) {
await expect(
probeWorkspacePortPage({ host: 'localhost', port, protocol: 'http' })
).resolves.toEqual({ kind: 'not-page' })
}
})
it('never reaches past loopback', async () => {
let requested = false
const port = await serve((_request, response) => {
requested = true
response.writeHead(200, { 'content-type': 'text/html' }).end('<title>x</title>')
})
await expect(
probeWorkspacePortPage({ host: 'example.com', port, protocol: 'http' })
).resolves.toEqual({ kind: 'not-page' })
expect(requested).toBe(false)
})
})
+186
View File
@@ -0,0 +1,186 @@
import http from 'node:http'
import https from 'node:https'
import {
LOOPBACK_LOCALHOST_HOSTS,
connectableLoopbackHost,
normalizeLocalhostHostname
} from '../../shared/localhost-worktree-labels'
import type {
WorkspacePortPageProbeRequest,
WorkspacePortPageProbeResult
} from '../../shared/workspace-ports'
const PROBE_TIMEOUT_MS = 1500
const MAX_PAGE_BYTES = 256 * 1024
const MAX_FAVICON_BYTES = 64 * 1024
const MAX_REDIRECTS = 3
const NOT_PAGE: WorkspacePortPageProbeResult = { kind: 'not-page' }
type ProbeResponse = {
status: number
location: string | undefined
contentType: string
body: Buffer
}
/** GETs a loopback listener's root and reports whether it serves an HTML page, with its title and icon. */
export async function probeWorkspacePortPage(
request: WorkspacePortPageProbeRequest
): Promise<WorkspacePortPageProbeResult> {
const origin = loopbackOrigin(request)
if (!origin) {
return NOT_PAGE
}
let url = new URL('/', origin)
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
const response = await fetchLoopback(url, MAX_PAGE_BYTES)
if (!response) {
return NOT_PAGE
}
if (response.status >= 300 && response.status < 400 && response.location) {
const next = new URL(response.location, url)
// Why same origin only: the probe answers for this listener, not wherever it points.
if (next.origin !== url.origin) {
return NOT_PAGE
}
url = next
continue
}
if (response.status < 200 || response.status >= 300) {
return NOT_PAGE
}
if (!response.contentType.toLowerCase().includes('text/html')) {
return NOT_PAGE
}
const html = response.body.toString('utf8')
return {
kind: 'page',
title: extractHtmlTitle(html),
faviconDataUrl: await fetchFaviconDataUrl(html, url)
}
}
return NOT_PAGE
}
function loopbackOrigin({ host, port, protocol }: WorkspacePortPageProbeRequest): string | null {
const bare = normalizeLocalhostHostname(host)
if (!LOOPBACK_LOCALHOST_HOSTS.has(bare) || !Number.isInteger(port) || port < 1 || port > 65535) {
return null
}
const connectHost = connectableLoopbackHost(bare)
const urlHost = connectHost.includes(':') ? `[${connectHost}]` : connectHost
return `${protocol === 'https' ? 'https' : 'http'}://${urlHost}:${port}`
}
function fetchLoopback(url: URL, maxBytes: number): Promise<ProbeResponse | null> {
return new Promise((resolve) => {
const client = url.protocol === 'https:' ? https : http
const request = client.get(
url,
{
agent: false,
headers: { accept: 'text/html,image/*;q=0.9,*/*;q=0.8' },
// Why: loopback dev servers use self-signed certificates, and only a title and icon leave.
rejectUnauthorized: false,
timeout: PROBE_TIMEOUT_MS
},
(response) => {
const chunks: Buffer[] = []
let size = 0
const finish = (): void =>
resolve({
status: response.statusCode ?? 0,
location: response.headers.location,
contentType: response.headers['content-type'] ?? '',
body: Buffer.concat(chunks)
})
response.on('data', (chunk: Buffer) => {
size += chunk.length
chunks.push(chunk)
// A title sits near the top, so a large page is read only up to the cap.
if (size >= maxBytes) {
response.destroy()
finish()
}
})
response.on('end', finish)
response.on('error', () => resolve(null))
}
)
request.on('timeout', () => request.destroy())
request.on('error', () => resolve(null))
})
}
function extractHtmlTitle(html: string): string | null {
const match = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html)
const title = match ? decodeHtmlEntities(match[1]).replace(/\s+/g, ' ').trim() : ''
return title ? title.slice(0, 120) : null
}
function decodeHtmlEntities(text: string): string {
const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, body: string) => {
if (body[0] === '#') {
const code =
body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10)
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: entity
}
return named[body.toLowerCase()] ?? entity
})
}
function findIconHref(html: string): string | null {
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
const attributes = new Map<string, string>()
for (const [, name, double, single, bare] of tag.matchAll(
/\b(rel|href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi
)) {
attributes.set(name.toLowerCase(), double ?? single ?? bare ?? '')
}
const rel = attributes.get('rel')?.toLowerCase().split(/\s+/) ?? []
const href = attributes.get('href')
if (rel.includes('icon') && href) {
return decodeHtmlEntities(href)
}
}
return null
}
async function fetchFaviconDataUrl(html: string, pageUrl: URL): Promise<string | null> {
const href = findIconHref(html) ?? '/favicon.ico'
if (href.startsWith('data:image/')) {
return href.length <= MAX_FAVICON_BYTES * 2 ? href : null
}
let iconUrl: URL
try {
iconUrl = new URL(href, pageUrl)
} catch {
return null
}
if (iconUrl.origin !== pageUrl.origin) {
return null
}
const response = await fetchLoopback(iconUrl, MAX_FAVICON_BYTES + 1)
if (
!response ||
response.status !== 200 ||
response.body.length === 0 ||
response.body.length > MAX_FAVICON_BYTES
) {
return null
}
const contentType = response.contentType.split(';')[0].trim().toLowerCase()
// Why the extension only for untyped bodies: SPA dev servers answer every path with index.html.
const untyped = contentType === '' || contentType === 'application/octet-stream'
const imageType = contentType.startsWith('image/')
? contentType
: untyped && iconUrl.pathname.toLowerCase().endsWith('.ico')
? 'image/x-icon'
: null
return imageType ? `data:${imageType};base64,${response.body.toString('base64')}` : null
}
@@ -2,9 +2,11 @@ import { defineMethod } from '../core'
import {
WorkspacePortKillHostParams,
WorkspacePortKillParams,
WorkspacePortProbePageParams,
WorkspacePortScanHostParams,
WorkspacePortScanParams
} from '../../../../shared/rpc-contract/workspace-ports-params'
import { probeWorkspacePortPage } from '../../../ports/workspace-port-page-probe'
export const WORKSPACE_PORT_METHODS = [
defineMethod({
@@ -24,6 +26,12 @@ export const WORKSPACE_PORT_METHODS = [
port: params.port
})
}),
defineMethod({
name: 'workspacePorts.probePage',
permission: 'workspace',
params: WorkspacePortProbePageParams,
handler: async (params) => probeWorkspacePortPage(params)
}),
defineMethod({
name: 'workspacePorts.scanHost',
permission: 'workspace',
+3
View File
@@ -6,6 +6,8 @@ import type {
WorkspacePortAdvertisedUrlChangedEvent,
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortPageProbeRequest,
WorkspacePortPageProbeResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
@@ -13,6 +15,7 @@ import type {
export type WorkspacePortsApi = {
scan: (args: WorkspacePortScanRequest) => Promise<WorkspacePortScanResult>
kill: (args: WorkspacePortKillRequest) => Promise<WorkspacePortKillResult>
probePage: (args: WorkspacePortPageProbeRequest) => Promise<WorkspacePortPageProbeResult>
onAdvertisedUrlChanged: (
callback: (event: WorkspacePortAdvertisedUrlChangedEvent) => void
) => () => void
@@ -5,6 +5,7 @@ import type { PreloadApi } from '../api-types'
export const workspacePortsApi = {
scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args),
kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args),
probePage: (args) => ipcRenderer.invoke('workspacePorts:probePage', args),
onAdvertisedUrlChanged: (callback) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -40,6 +40,8 @@ import { isPageDocumentNavigationStart } from './host-guest/browser-page-webview
import { BrowserLoadFailureOverlay } from './navigate/browser-load-failure-overlay'
import { useClientHostedPageUrlSubmission } from './navigate/use-client-hosted-page-url-submission'
import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview'
import { isBlankBrowserUrl } from '@/lib/browser-palette-search'
import { BrowserNewTabPage } from './new-tab/BrowserNewTabPage'
import { useBrowserPageReloadActions } from './navigate/use-browser-page-reload-actions'
import { resolveActiveBrowserLoadFailure } from './navigate/browser-load-failure-for-url'
import { consumeBrowserPageDeferredNavigation } from './navigate/browser-page-deferred-navigation'
@@ -428,6 +430,8 @@ export function ClientHostedBrowserPagePane({
lastCommittedUrl={browserTab.url}
placementHostClientId={browserHostClientId}
/>
) : isBlankBrowserUrl(browserTab.url) ? (
<BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={navigateToUrl} serversElsewhere />
) : null}
</div>
</div>
@@ -217,8 +217,11 @@ export default function BrowserAddressBar({
}
inputRef.current?.select()
openedAtRef.current = Date.now()
setOpen(true)
}, [inputRef])
// Why: an empty bar is a blank tab, whose New Tab page already lists what the dropdown would.
if (value !== '') {
setOpen(true)
}
}, [inputRef, value])
const handleBlur = useCallback(() => {
// Why: delay close so that clicking a suggestion item registers before
@@ -1,6 +1,5 @@
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react'
import { BrowserPageZoomIndicator } from './browser-page-zoom-indicator'
import { Globe } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { toHttpsRecoveryUrl } from '../../../../../shared/browser-url'
import type {
@@ -11,6 +10,7 @@ import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from '../host-guest/browser-page-gu
import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay'
import { useSshWorkspaceProbeSkipRecheck } from '../use-ssh-workspace-browser-route'
import BrowserFind from './BrowserFind'
import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage'
import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays'
import type { MarkupModeController } from '../annotate/useMarkupMode'
import type { GrabModeHook } from '../annotate/useGrabMode'
@@ -126,26 +126,7 @@ export function BrowserPageViewportOverlays({
}
/>
) : 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">
{translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{translate(
'auto.components.browser.pane.BrowserPane.f796c774a4',
'Type a URL above to start browsing.'
)}
</p>
</div>
</div>
</div>
) : null}
{isBlankTab ? <BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={navigateToUrl} /> : null}
</>
)
}
@@ -408,7 +408,7 @@ describe.each([
expect(document.activeElement).toBe(adopted)
expect([adopted.selectionStart, adopted.selectionEnd]).toEqual([3, 7])
expect(adopted.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('Example docs')).not.toBeNull()
expect(screen.getByRole('option', { name: /Example docs/ })).not.toBeNull()
})
// Why the draft alone is not enough here: while a suggestion is previewed the input holds that
@@ -498,7 +498,7 @@ describe.each([
expect(adopted.value).toBe('example.int')
expect(document.activeElement).toBe(adopted)
expect(adopted.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('Example docs')).toBeNull()
expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull()
})
// Why: the swap tears the bar down and puts it back inside one React commit. A pane that goes
@@ -531,7 +531,7 @@ describe.each([
// over the page, by a handover they never asked for.
expect(document.activeElement).not.toBe(adopted)
expect(adopted.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('Example docs')).toBeNull()
expect(screen.queryByRole('option', { name: /Example docs/ })).toBeNull()
})
// The pane is no longer keyed on the generation, so a bump re-runs the attach rather than
@@ -1,4 +1,5 @@
import { ORCA_BROWSER_PAGE_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
import { isBlankBrowserUrl } from '@/lib/browser-palette-search'
export function createBrowserClientPageRetainedRoot(document: Document): HTMLDivElement {
const root = document.createElement('div')
@@ -33,6 +34,21 @@ export function createBrowserClientPageRetainedHost(
return host
}
/**
* The guest's `load-commit` listener that hides its host on a blank page. Why the host and not the
* guest: the host is a click target above the pane, so a blank tab must drop both its paint and its
* hit testing for the New Tab page underneath.
*/
export function hideRetainedHostWhileGuestIsBlank(
host: HTMLDivElement
): (event: Electron.LoadCommitEvent) => void {
return (event) => {
if (event.isMainFrame) {
host.style.visibility = isBlankBrowserUrl(event.url) ? 'hidden' : ''
}
}
}
export function createBrowserClientPageWebview(options: {
createWebview?: () => Electron.WebviewTag
document: Document
@@ -92,6 +92,25 @@ describe('browser client page retained registry', () => {
await expect(mounted).resolves.toEqual({ webContentsId: 41 })
})
// The host is a click target painted above the pane, so the New Tab page beneath a blank guest
// is only usable while the whole host is hidden, not just the guest.
it('hides the retained host while its guest shows a blank page', async () => {
const { registry, webviews } = createRig()
void registry.mountPage(PAGE)
const webview = webviews[0]!
const host = webview.parentElement!
const commit = (url: string, isMainFrame = true): void => {
webview.dispatchEvent(Object.assign(new Event('load-commit'), { url, isMainFrame }))
}
commit('about:blank')
expect(host.style.visibility).toBe('hidden')
commit('https://example.com/frame', false)
expect(host.style.visibility).toBe('hidden')
commit('https://example.com/')
expect(host.style.visibility).toBe('')
})
// The guest lives in a fixed-position overlay, never inside its pane, so nothing in the DOM says
// which row a `<webview>` belongs to unless the host carries the page id. The paired restart e2e
// binds its marker read to exactly this stamp.
@@ -7,6 +7,7 @@ import {
createBrowserClientPageRetainedRoot,
createBrowserClientPageWebview,
hasBrowserClientPageAttachedGuest,
hideRetainedHostWhileGuestIsBlank,
readBrowserClientPageAttachedGuestId
} from './browser-client-page-retained-elements'
import { browserClientPageRetainedKey } from './browser-client-page-retained-key'
@@ -196,13 +197,15 @@ export class BrowserClientPageRetainedRegistry {
onAttached,
onReady,
onDestroyed,
onRendererGone
onRendererGone,
onLoadCommit: hideRetainedHostWhileGuestIsBlank(host)
})
page.releaseDragPassthroughSurface = enrolRetainedHostDragPassthrough(page)
webview.addEventListener('did-attach', onAttached)
webview.addEventListener('dom-ready', onReady)
webview.addEventListener('destroyed', onDestroyed)
webview.addEventListener('render-process-gone', onRendererGone)
webview.addEventListener('load-commit', page.onLoadCommit)
this.pages.set(key, page)
this.partitionCounts.set(
identity.partition,
@@ -286,6 +289,7 @@ export class BrowserClientPageRetainedRegistry {
page.webview.removeEventListener('dom-ready', page.onReady)
page.webview.removeEventListener('destroyed', page.onDestroyed)
page.webview.removeEventListener('render-process-gone', page.onRendererGone)
page.webview.removeEventListener('load-commit', page.onLoadCommit)
page.visibleAttachment = null
page.webview.remove()
page.host.remove()
@@ -18,5 +18,6 @@ export type BrowserClientRetainedRendererPage = {
onReady: EventListener
onDestroyed: EventListener
onRendererGone: EventListener
onLoadCommit: (event: Electron.LoadCommitEvent) => void
releaseDragPassthroughSurface: () => void
}
@@ -0,0 +1,231 @@
import { useMemo } from 'react'
import { Globe } from 'lucide-react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { useRepoById, useWorktreeById } from '@/store/selectors'
import { prepareBrowserHistoryEntries, rankBrowserTopSites } from '@/lib/browser-history-match'
import { BrowserFavicon } from '@/components/browser-favicon'
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import type { WorkspaceLinkedItemRow } from '../../sidebar/workspace-hover-attachments'
import { BrowserNewTabTile } from './browser-new-tab-tile'
import { useBrowserNewTabLinkedItems } from './use-browser-new-tab-linked-items'
import { useBrowserNewTabServers, type BrowserNewTabServers } from './use-browser-new-tab-servers'
import { BrowserNewTabWorkspaceCard } from './browser-new-tab-workspace-card'
import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
const SUGGESTED_TILE_LIMIT = 8
const NO_LINKED_ITEMS: WorkspaceLinkedItemRow[] = []
type BrowserNewTabServerContext = {
servers: BrowserNewTabServers
runtimeTarget: RuntimeClientTarget | null
/** False when this tab's page runs on another machine than the workspace's ports. */
openInPlace: boolean
}
/** The page a blank browser tab shows: this workspace's servers and links, then the sites you visit most. */
export function BrowserNewTabPage({
worktreeId,
onOpenUrl,
serversElsewhere = false
}: {
worktreeId: string
onOpenUrl: (url: string) => void
/** This tab's page runs on another machine than the workspace's ports, so they open on theirs. */
serversElsewhere?: boolean
}): React.JSX.Element {
const worktree = useWorktreeById(worktreeId)
const repo = useRepoById(worktree?.repoId ?? null) ?? undefined
const runtimeTarget = useWorktreeRuntimeTarget(worktreeId)
const servers: BrowserNewTabServerContext = {
servers: useBrowserNewTabServers(worktreeId, runtimeTarget),
runtimeTarget,
openInPlace: !serversElsewhere
}
return worktree ? (
<BrowserNewTabWorktreePage
worktree={worktree}
repo={repo}
servers={servers}
onOpenUrl={onOpenUrl}
/>
) : (
<BrowserNewTabPageLayout
linkedItems={NO_LINKED_ITEMS}
servers={servers}
onOpenUrl={onOpenUrl}
/>
)
}
function BrowserNewTabWorktreePage({
worktree,
repo,
servers,
onOpenUrl
}: {
worktree: Worktree
repo: Repo | undefined
servers: BrowserNewTabServerContext
onOpenUrl: (url: string) => void
}): React.JSX.Element {
const linkedItems = useBrowserNewTabLinkedItems(worktree, repo)
return (
<BrowserNewTabPageLayout linkedItems={linkedItems} servers={servers} onOpenUrl={onOpenUrl} />
)
}
function BrowserNewTabPageLayout({
linkedItems,
servers: { servers, runtimeTarget, openInPlace },
onOpenUrl
}: {
linkedItems: WorkspaceLinkedItemRow[]
servers: BrowserNewTabServerContext
onOpenUrl: (url: string) => void
}): React.JSX.Element {
const browserUrlHistory = useAppStore((s) => s.browserUrlHistory)
const removeBrowserHistoryEntry = useAppStore((s) => s.removeBrowserHistoryEntry)
const restoreBrowserHistoryEntry = useAppStore((s) => s.restoreBrowserHistoryEntry)
const suggested = useMemo(
() =>
rankBrowserTopSites({
prepared: prepareBrowserHistoryEntries(browserUrlHistory),
limit: SUGGESTED_TILE_LIMIT
}),
[browserUrlHistory]
)
const openableLinkedItems = linkedItems.filter((row) => row.url)
const hasWorkspaceRows =
Boolean(runtimeTarget && (servers.own.length > 0 || servers.others.length > 0)) ||
openableLinkedItems.length > 0
const removeSuggestion = (entry: BrowserHistoryEntry): void => {
const removed = removeBrowserHistoryEntry(entry.normalizedUrl)
if (!removed) {
return
}
toast(
translate('browser.newTab.removedFromHistory', 'Removed “{{title}}” from history', {
title: suggestionLabel(removed)
}),
{
action: {
label: translate('browser.newTab.undo', 'Undo'),
onClick: () => restoreBrowserHistoryEntry(removed)
}
}
)
}
if (!hasWorkspaceRows && suggested.length === 0) {
return (
<BrowserNewTabScroller>
<div className="flex flex-col items-center gap-2.5 px-4 pt-16 text-center">
<div className="mb-1.5 grid size-13 place-items-center rounded-full border border-border text-muted-foreground">
<Globe className="size-5" />
</div>
<p className="text-base font-semibold text-foreground">
{translate('auto.components.browser.pane.BrowserPane.366bf5d62c', 'New Tab')}
</p>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
{translate(
'browser.newTab.emptyHint',
'Search or enter an address above. Sites you visit, dev servers running in this workspace, and its PR and issue will show up here.'
)}
</p>
</div>
</BrowserNewTabScroller>
)
}
return (
<BrowserNewTabScroller>
{hasWorkspaceRows ? (
<BrowserNewTabSection label={translate('browser.newTab.thisWorkspace', 'This workspace')}>
<BrowserNewTabWorkspaceCard
servers={servers}
linkedItems={openableLinkedItems}
runtimeTarget={runtimeTarget}
openServersInPlace={openInPlace}
onOpenUrl={onOpenUrl}
/>
</BrowserNewTabSection>
) : null}
{suggested.length > 0 ? (
<BrowserNewTabSection label={translate('browser.newTab.suggested', 'Suggested')}>
<BrowserNewTabTileGrid>
{suggested.map((entry) => (
<BrowserNewTabTile
key={entry.normalizedUrl}
icon={
<BrowserFavicon
faviconUrl={entry.faviconUrl}
className="size-6"
fallbackClassName="size-5 text-muted-foreground"
/>
}
label={suggestionLabel(entry)}
tooltip={entry.title.trim() ? `${entry.title} · ${entry.url}` : entry.url}
onOpen={() => onOpenUrl(entry.url)}
onRemove={() => removeSuggestion(entry)}
removeLabel={translate('browser.newTab.removeSuggestion', 'Remove from history')}
/>
))}
</BrowserNewTabTileGrid>
</BrowserNewTabSection>
) : null}
</BrowserNewTabScroller>
)
}
function BrowserNewTabScroller({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="scrollbar-sleek @container absolute inset-0 z-10 overflow-y-auto bg-background">
<div className="mx-auto flex max-w-[680px] flex-col gap-8 px-3 pt-14 pb-12 @xs:px-6">
{children}
</div>
</div>
)
}
function BrowserNewTabSection({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<section className="flex flex-col gap-2">
<h2 className="px-2 text-2xs font-semibold tracking-[0.05em] text-muted-foreground uppercase">
{label}
</h2>
{children}
</section>
)
}
function BrowserNewTabTileGrid({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
// Why steps above the smallest panes: eight tiles land in full rows of 4 or 8.
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.25rem,1fr))] gap-1 @[20rem]:grid-cols-4 @[39rem]:grid-cols-8">
{children}
</div>
)
}
function suggestionLabel(entry: BrowserHistoryEntry): string {
if (entry.title.trim()) {
return entry.title
}
try {
return new URL(entry.url).host.replace(/^www\./, '')
} catch {
return entry.url
}
}
@@ -0,0 +1,29 @@
/** One row of the workspace card: an icon, a title over a detail line, and optional trailing state. */
export function BrowserNewTabRow({
icon,
title,
detail,
trailing,
onOpen
}: {
icon: React.ReactNode
title: string
detail: React.ReactNode
trailing?: React.ReactNode
onOpen: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onOpen}
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-2 py-1.5 text-left outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-muted">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{title}</span>
<span className="block truncate text-xs text-muted-foreground">{detail}</span>
</span>
{trailing}
</button>
)
}
@@ -0,0 +1,76 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { BrowserFavicon } from '@/components/browser-favicon'
import { useLocalhostLabelRouteForPort } from '@/lib/workspace-port-localhost-label-selector'
import { addressForPort } from '@/lib/workspace-port-urls'
import {
openWorkspacePortInBrowser,
resolveWorkspacePortBrowserUrl
} from '@/lib/workspace-port-actions'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import { BrowserNewTabRow } from './browser-new-tab-row'
import type { BrowserNewTabServer } from './use-browser-new-tab-servers'
export function BrowserNewTabServerRow({
server,
runtimeTarget,
openInPlace,
showOwner = false,
onOpenUrl
}: {
server: BrowserNewTabServer
runtimeTarget: RuntimeClientTarget
/** False when this tab's page runs on another machine than the port, which only a new tab on the port's host can reach. */
openInPlace: boolean
/** Names the worktree the server belongs to, for servers of other workspaces. */
showOwner?: boolean
onOpenUrl: (url: string) => void
}): React.JSX.Element {
const { port, title, faviconDataUrl } = server
const localhostLabelRoute = useLocalhostLabelRouteForPort(port)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
const address = addressForPort(port)
const context = showOwner && port.kind === 'workspace' ? port.owner.displayName : port.processName
const open = async (): Promise<void> => {
if (openInPlace) {
onOpenUrl(await resolveWorkspacePortBrowserUrl({ port, runtimeTarget, localhostLabelRoute }))
return
}
const result = await openWorkspacePortInBrowser({
port,
runtimeTarget,
createBrowserTab,
setRemoteBrowserPageHandle,
localhostLabelRoute
})
if (!result.ok) {
toast.error(
translate('browser.newTab.openServerFailed', 'Couldn’t open {{address}}', { address }),
{ description: result.reason }
)
}
}
return (
<BrowserNewTabRow
icon={
<BrowserFavicon
faviconUrl={faviconDataUrl}
className="size-4"
fallbackClassName="text-muted-foreground"
/>
}
title={title ?? port.processName ?? address}
detail={
<>
<span className="font-mono">{address}</span>
{context ? ` · ${context}` : null}
</>
}
onOpen={() => void open()}
/>
)
}
@@ -0,0 +1,50 @@
import { X } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export function BrowserNewTabTile({
icon,
label,
tooltip,
onOpen,
onRemove,
removeLabel
}: {
icon: React.ReactNode
label: string
tooltip: string
onOpen: () => void
onRemove?: () => void
removeLabel?: string
}): React.JSX.Element {
return (
<div className="group/tile relative min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onOpen}
className="flex w-full min-w-0 flex-col items-center gap-2 rounded-xl px-1.5 pt-3.5 pb-2.5 outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="grid size-12 place-items-center rounded-xl border border-border bg-muted">
{icon}
</span>
<span className="max-w-full truncate text-xs text-foreground">{label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{tooltip}
</TooltipContent>
</Tooltip>
{onRemove ? (
<button
type="button"
aria-label={removeLabel}
onClick={onRemove}
className="absolute top-1 right-1 grid size-5 place-items-center rounded-full border border-border bg-popover text-muted-foreground opacity-0 outline-none group-hover/tile:opacity-100 group-focus-within/tile:opacity-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
) : null}
</div>
)
}
@@ -0,0 +1,211 @@
import { useState } from 'react'
import { ChevronRight, CircleDot, GitMerge, GitPullRequest } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { getReviewStateTone } from '@/components/github/review-state-presentation'
import { workspaceAttachmentReferenceLabel } from '../../../../../shared/workspace-attachments'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type { HostedReviewState } from '../../../../../shared/hosted-review'
import type { CheckStatus } from '../../../../../shared/github/pull-request-types'
import { ReviewIcon } from '../../sidebar/worktree-review-helpers'
import { workspaceAttachmentLabel } from '../../sidebar/worktree-attachment-editing'
import type { WorkspaceLinkedItemRow } from '../../sidebar/workspace-hover-attachments'
import { BrowserNewTabRow } from './browser-new-tab-row'
import { BrowserNewTabServerRow } from './browser-new-tab-server-row'
import type { BrowserNewTabServers } from './use-browser-new-tab-servers'
/** This workspace's running servers and linked reviews and tasks, then servers elsewhere on its host. */
export function BrowserNewTabWorkspaceCard({
servers,
linkedItems,
runtimeTarget,
openServersInPlace,
onOpenUrl
}: {
servers: BrowserNewTabServers
linkedItems: WorkspaceLinkedItemRow[]
runtimeTarget: RuntimeClientTarget | null
openServersInPlace: boolean
onOpenUrl: (url: string) => void
}): React.JSX.Element {
const [othersOpen, setOthersOpen] = useState(false)
const own = runtimeTarget ? servers.own : []
const others = runtimeTarget ? servers.others : []
const serverRow = (server: BrowserNewTabServers['own'][number], showOwner: boolean) =>
runtimeTarget ? (
<BrowserNewTabServerRow
key={`${server.port.connectHost}:${server.port.port}`}
server={server}
runtimeTarget={runtimeTarget}
openInPlace={openServersInPlace}
showOwner={showOwner}
onOpenUrl={onOpenUrl}
/>
) : null
return (
<div className="flex flex-col gap-1.5">
{own.length > 0 || linkedItems.length > 0 ? (
<div className="flex flex-col gap-px rounded-xl border border-border bg-card p-1">
{own.map((server) => serverRow(server, false))}
{own.length > 0 && linkedItems.length > 0 ? (
<div className="mx-2 my-1 h-px bg-border" />
) : null}
{linkedItems.map((row) => (
<LinkedItemRow key={row.key} row={row} onOpenUrl={onOpenUrl} />
))}
</div>
) : null}
{others.length > 0 ? (
<>
<button
type="button"
aria-expanded={othersOpen}
onClick={() => setOthersOpen((open) => !open)}
className="flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight
className={cn('size-3.5 transition-transform motion-reduce:transition-none', {
'rotate-90': othersOpen
})}
/>
{translate('browser.newTab.runningElsewhere', '{{count}} running in other workspaces', {
count: others.length
})}
</button>
{othersOpen ? (
<div className="flex flex-col gap-px">
{others.map((server) => serverRow(server, true))}
</div>
) : null}
</>
) : null}
</div>
)
}
function LinkedItemRow({
row,
onOpenUrl
}: {
row: WorkspaceLinkedItemRow
onOpenUrl: (url: string) => void
}): React.JSX.Element {
const { item, detail, live, url } = row
const reference = workspaceAttachmentReferenceLabel(item, {
pr: (number) => `#${number}`,
issue: (number) => `#${number}`,
mr: (number) => `!${number}`
})
const status = live ? reviewChecksLabel(live.status) : linkedTaskState(row)
const reviewState = live?.state ? reviewStateLabel(live.state) : undefined
return (
<BrowserNewTabRow
icon={<LinkedItemIcon row={row} />}
title={detail?.title || item.title || live?.title || workspaceAttachmentLabel(item)}
detail={
<>
<span className="font-mono">{reference}</span>
{status ? ` · ${status}` : null}
</>
}
trailing={
reviewState && live?.state ? (
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-2xs font-medium',
getReviewStateTone(live.state)
)}
>
{reviewState}
</span>
) : null
}
onOpen={() => {
if (url) {
onOpenUrl(url)
}
}}
/>
)
}
function LinkedItemIcon({ row }: { row: WorkspaceLinkedItemRow }): React.JSX.Element {
const { item, live } = row
if (live) {
return <ReviewIcon review={live} className="size-4" />
}
if (item.provider === 'linear') {
return <LinearIcon className="size-4 text-muted-foreground" />
}
if (item.provider === 'jira') {
return <JiraIcon className="size-4 text-muted-foreground" />
}
if (item.type === 'mr') {
return <GitMerge className="size-4 text-muted-foreground" />
}
if (item.type === 'pr') {
return <GitPullRequest className="size-4 text-muted-foreground" />
}
return (
<CircleDot
className={
linkedIssueState(row) === 'open'
? 'size-4 text-workspace-status-review/80'
: 'size-4 text-muted-foreground'
}
/>
)
}
function reviewStateLabel(state: HostedReviewState): string {
if (state === 'draft') {
return translate('browser.newTab.state.draft', 'Draft')
}
if (state === 'merged') {
return translate('browser.newTab.state.merged', 'Merged')
}
if (state === 'closed') {
return translate('browser.newTab.state.closed', 'Closed')
}
return translate('browser.newTab.state.open', 'Open')
}
function reviewChecksLabel(status: CheckStatus | undefined): string | undefined {
if (status === 'success') {
return translate('browser.newTab.checks.passing', 'Checks passing')
}
if (status === 'failure') {
return translate('browser.newTab.checks.failing', 'Checks failing')
}
if (status === 'pending') {
return translate('browser.newTab.checks.pending', 'Checks pending')
}
return undefined
}
function linkedTaskState(row: WorkspaceLinkedItemRow): string | undefined {
const { detail, liveTask } = row
const stateName =
detail?.stateName ?? (liveTask && 'stateName' in liveTask ? liveTask.stateName : undefined)
if (stateName) {
return stateName
}
const issueState = linkedIssueState(row)
if (issueState === 'open') {
return translate('browser.newTab.state.open', 'Open')
}
if (issueState === 'closed') {
return translate('browser.newTab.state.closed', 'Closed')
}
return undefined
}
function linkedIssueState({
detail,
liveTask
}: Pick<WorkspaceLinkedItemRow, 'detail' | 'liveTask'>): 'open' | 'closed' | undefined {
return detail?.issueState ?? (liveTask && 'number' in liveTask ? liveTask.state : undefined)
}
@@ -0,0 +1,100 @@
import type { Repo } from '../../../../../shared/repo-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { useWorktreeCardFoundation } from '../../sidebar/use-worktree-card-foundation'
import { useWorktreeCardReviewDetails } from '../../sidebar/use-worktree-card-review-details'
import { useWorktreeCardLinkedDetails } from '../../sidebar/use-worktree-card-linked-details'
import { useWorktreeCardLifecycleEffects } from '../../sidebar/use-worktree-card-lifecycle-effects'
import {
getWorkspaceLinkedItemRows,
type WorkspaceLinkedItemRow
} from '../../sidebar/workspace-hover-attachments'
/** The worktree's linked reviews and tasks, fetched and resolved the way its sidebar card does. */
export function useBrowserNewTabLinkedItems(
worktree: Worktree,
repo: Repo | undefined
): WorkspaceLinkedItemRow[] {
const foundation = useWorktreeCardFoundation({ worktree, repo })
const review = useWorktreeCardReviewDetails({
worktree,
repo,
settings: foundation.settings,
projectGroups: foundation.projectGroups,
cardProps: foundation.cardProps,
newCardStyle: foundation.newCardStyle
})
const linked = useWorktreeCardLinkedDetails({
worktree,
newCardStyle: foundation.newCardStyle,
deleteState: foundation.deleteState,
branch: review.branch,
issueEntry: review.issueEntry,
linearIssueEntry: review.linearIssueEntry,
linearIssueFallbackEntry: review.linearIssueFallbackEntry,
prDisplay: review.prDisplay
})
// Why details open: the page shows titles and states up front, as the card's hover does.
useWorktreeCardLifecycleEffects({
worktree,
repo,
isFolder: review.isFolder,
hostedReviewCacheKey: review.hostedReviewCacheKey,
cachedBranchFallbackGitHubPRNumber: review.cachedBranchFallbackGitHubPRNumber,
linkedGitLabMR: review.linkedGitLabMR,
linkedBitbucketPR: review.linkedBitbucketPR,
linkedAzureDevOpsPR: review.linkedAzureDevOpsPR,
linkedGiteaPR: review.linkedGiteaPR,
branch: review.branch,
fetchHostedReviewForBranch: foundation.fetchHostedReviewForBranch,
shouldRefreshHostedReview: false,
newCardStyle: true,
hoverDetailsOpen: true,
showIssue: true,
issueCacheKey: review.issueCacheKey,
fetchIssue: foundation.fetchIssue,
showLinearIssue: true,
fetchLinearIssue: foundation.fetchLinearIssue,
linearSourceContext: review.linearSourceContext,
linearWorkspaceId: review.linearWorkspaceId
})
const rows = getWorkspaceLinkedItemRows(
worktree,
{
review: review.prDisplay,
issue: linked.issueDisplay,
linearIssue: linked.linearIssue,
jiraIssue: linked.jiraIssueDisplay
},
// Why no per-item details: their lookup routes by the focused server, which this page must not.
undefined
)
// Why: the card also shows the review found from the branch, which no saved link may name.
const branchReview = review.prDisplay
if (
!branchReview?.url ||
branchReview.provider === 'unsupported' ||
rows.some(
(row) =>
row.item.type !== 'issue' &&
row.item.provider === branchReview.provider &&
row.item.number === branchReview.number
)
) {
return rows
}
const branchRow: WorkspaceLinkedItemRow = {
item: {
provider: branchReview.provider,
type: branchReview.provider === 'gitlab' ? 'mr' : 'pr',
number: branchReview.number,
title: branchReview.title,
url: branchReview.url
},
key: `branch-review:${branchReview.provider}:${branchReview.number}`,
url: branchReview.url,
detail: undefined,
live: branchReview,
liveTask: null
}
return [branchRow, ...rows]
}
@@ -0,0 +1,128 @@
import { useEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
import { workspacePortScanKeyForTarget } from '@/lib/workspace-port-actions'
import { refreshWorkspacePortScanForTarget } from '@/lib/workspace-port-scan-refresh'
import { probeWorkspacePortPageForTarget } from '@/lib/workspace-port-scan-client'
import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target'
import type {
WorkspacePort,
WorkspacePortPageProbeResult
} from '../../../../../shared/workspace-ports'
export type BrowserNewTabServer = {
port: WorkspacePort
title: string | null
faviconDataUrl: string | null
}
// Why a short cache: every new tab asks again, and a dev server's page rarely changes in seconds.
const PROBE_TTL_MS = 15_000
const probeCache = new Map<
string,
{ probedAt: number; result: Promise<WorkspacePortPageProbeResult | null> }
>()
function probeKey(target: RuntimeClientTarget, port: WorkspacePort): string {
return `${workspacePortScanKeyForTarget(target)}|${port.connectHost}|${port.port}|${port.pid ?? ''}`
}
function probePortPage(
target: RuntimeClientTarget,
port: WorkspacePort
): Promise<WorkspacePortPageProbeResult | null> {
const key = probeKey(target, port)
const cached = probeCache.get(key)
if (cached && Date.now() - cached.probedAt < PROBE_TTL_MS) {
return cached.result
}
const advertisedHttps = port.kind === 'workspace' && port.advertisedUrl?.startsWith('https:')
const result = probeWorkspacePortPageForTarget(target, {
host: port.connectHost,
port: port.port,
protocol: advertisedHttps || port.protocol === 'https' ? 'https' : 'http'
}).catch((): WorkspacePortPageProbeResult => ({ kind: 'not-page' }))
probeCache.set(key, { probedAt: Date.now(), result })
return result
}
export type BrowserNewTabServers = {
/** This worktree's servers. */
own: BrowserNewTabServer[]
/** Other worktrees' servers on the same host, so this tab reaches them at the same address. */
others: BrowserNewTabServer[]
}
/** Listeners on this worktree's host that answer with a web page, freshly scanned on open. */
export function useBrowserNewTabServers(
worktreeId: string,
runtimeTarget: RuntimeClientTarget | null
): BrowserNewTabServers {
const hostScan = useAppStore((s) =>
runtimeTarget ? s.workspacePortScansByKey[workspacePortScanKeyForTarget(runtimeTarget)] : null
)
const ports = useMemo(
() => [...getWorkspacePortsByWorktreeId(hostScan).values()].flat(),
[hostScan]
)
const replaceWorkspacePortScans = useAppStore((s) => s.replaceWorkspacePortScans)
const [probes, setProbes] = useState<ReadonlyMap<string, WorkspacePortPageProbeResult | null>>(
() => new Map()
)
useEffect(() => {
if (!runtimeTarget) {
return
}
// Why: the background poll runs every 30s; a server started moments ago should be here now.
void refreshWorkspacePortScanForTarget({
runtimeTarget,
replaceWorkspacePortScans,
getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey
})
}, [runtimeTarget, replaceWorkspacePortScans])
useEffect(() => {
if (!runtimeTarget) {
return
}
let cancelled = false
for (const port of ports) {
const key = probeKey(runtimeTarget, port)
void probePortPage(runtimeTarget, port).then((result) => {
if (!cancelled) {
setProbes((previous) =>
previous.get(key) === result ? previous : new Map(previous).set(key, result)
)
}
})
}
return () => {
cancelled = true
}
}, [ports, runtimeTarget])
const servers = runtimeTarget
? ports.flatMap((port): BrowserNewTabServer[] => {
const probe = probes.get(probeKey(runtimeTarget, port))
if (probe === undefined) {
return []
}
if (probe === null) {
// An older host cannot say; a URL its terminal printed is the best evidence of a page.
return port.kind === 'workspace' && port.advertisedUrl
? [{ port, title: null, faviconDataUrl: null }]
: []
}
return probe.kind === 'page'
? [{ port, title: probe.title, faviconDataUrl: probe.faviconDataUrl }]
: []
})
: []
const ownedHere = (server: BrowserNewTabServer): boolean =>
server.port.kind === 'workspace' && server.port.owner.worktreeId === worktreeId
return {
own: servers.filter(ownedHere),
others: servers.filter((server) => !ownedHere(server))
}
}
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
@@ -226,7 +226,7 @@ describe('RemoteBrowserPagePane address bar parity', () => {
expect(input.value).toBe('')
})
it('offers the shared URL history as suggestions once the bar has focus', () => {
it('offers the shared URL history once the user types, not on a blank tab’s focus', () => {
useAppStore.setState({
browserUrlHistory: [
{
@@ -243,6 +243,9 @@ describe('RemoteBrowserPagePane address bar parity', () => {
renderPane()
// The New Tab page under the bar already lists history, so focus alone opens nothing.
expect(screen.queryByText('Google Maps')).toBeNull()
fireEvent.change(addressBar(), { target: { value: 'maps' } })
expect(screen.getByText('Google Maps')).toBeTruthy()
})
@@ -422,6 +422,7 @@ export function RemoteBrowserPagePane({
runtimeTarget={runtimeTarget}
onReload={() => void runRemoteNavigation('browser.reload')}
onGoto={(url) => void runRemoteNavigation('browser.goto', url)}
onNavigateToUrl={navigateToUrl}
onReconnect={reconnectRemoteStream}
handleRemotePointerDown={handleRemotePointerDown}
handleRemotePointerUp={handleRemotePointerUp}
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import { Globe, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { isBlankBrowserUrl } from '@/lib/browser-palette-search'
import type {
BrowserCertificateFailure,
BrowserCertificateProceedResult,
@@ -20,6 +20,7 @@ import type { MarkupModeController } from '../annotate/useMarkupMode'
import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-overlay'
import { ReopenBrowserPageOnServerButton } from '../ReopenBrowserPageOnServerButton'
import { toDisplayUrl } from '../describe-page/browser-page-url-display'
import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage'
import {
canReconnectRemoteBrowserStream,
type RemoteBrowserStreamStatus
@@ -46,6 +47,7 @@ export function RemoteBrowserPageViewport({
runtimeTarget,
onReload,
onGoto,
onNavigateToUrl,
onReconnect,
handleRemotePointerDown,
handleRemotePointerUp,
@@ -70,6 +72,8 @@ export function RemoteBrowserPageViewport({
runtimeTarget: () => RemoteBrowserRuntimeTarget | null
onReload: () => void
onGoto: (url: string) => void
/** The address bar's submit path, which also parks a URL typed before the host page exists. */
onNavigateToUrl: (url: string) => void
onReconnect: () => void
handleRemotePointerDown: (event: React.PointerEvent<HTMLImageElement>) => void
handleRemotePointerUp: (event: React.PointerEvent<HTMLImageElement>) => void
@@ -80,9 +84,7 @@ export function RemoteBrowserPageViewport({
const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url
const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl)
const showRemoteFailureOverlay =
Boolean(browserTab.loadError) &&
remoteFailureUrl !== 'about:blank' &&
remoteFailureUrl !== ORCA_BROWSER_BLANK_URL
Boolean(browserTab.loadError) && !isBlankBrowserUrl(remoteFailureUrl)
return (
<div
@@ -145,6 +147,9 @@ export function RemoteBrowserPageViewport({
</div>
</div>
)}
{isBlankBrowserUrl(browserTab.url) ? (
<BrowserNewTabPage worktreeId={worktreeId} onOpenUrl={onNavigateToUrl} />
) : null}
{showRemoteFailureOverlay && browserTab.loadError ? (
<BrowserLoadFailureOverlay
loadError={browserTab.loadError}
@@ -3,7 +3,6 @@ import { CircleDot, ExternalLink, GitMerge, GitPullRequest, Link2, Plus } from '
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { WorkspaceAttachment, Worktree } from '../../../../shared/worktree/types'
import { getWorkspaceAttachmentKey } from '../../../../shared/workspace-attachment-normalization'
import {
workspaceAttachmentLabel,
workspaceAttachmentProviderLabel
@@ -20,7 +19,7 @@ import {
import { WorkspaceAttachmentOrigins } from './WorkspaceAttachmentOrigins'
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
import type { WorkspaceReferenceDetailsMap } from './workspace-reference-details'
import { getWorkspaceHoverAttachments } from './workspace-hover-attachments'
import { getWorkspaceLinkedItemRows } from './workspace-hover-attachments'
import type {
WorktreeCardIssueDisplay,
WorktreeCardLinearIssueDisplay,
@@ -52,22 +51,21 @@ export function WorktreeCardLinkedItemsSection({
if (!workspace) {
return null
}
const { items, activeKey, referenceKeys } = getWorkspaceHoverAttachments(workspace, {
review,
issue,
linearIssue,
jiraIssue
})
const rows = getWorkspaceLinkedItemRows(
workspace,
{ review, issue, linearIssue, jiraIssue },
referenceDetails
)
const groups = [
{
label: translate('workspace.links.reviews', 'Reviews'),
items: items.filter((item) => item.type !== 'issue')
rows: rows.filter((row) => row.item.type !== 'issue')
},
{
label: translate('workspace.links.tasks', 'Issues & tasks'),
items: items.filter((item) => item.type === 'issue')
rows: rows.filter((row) => row.item.type === 'issue')
}
].filter((group) => group.items.length)
].filter((group) => group.rows.length)
const open = (item: WorkspaceAttachment, event: React.MouseEvent): void => {
event.stopPropagation()
if (item.url) {
@@ -87,24 +85,12 @@ export function WorktreeCardLinkedItemsSection({
<div key={group.label} className="space-y-1.5">
<DetailHeader
icon={<Link2 className="size-3" />}
label={`${group.label} · ${group.items.length}`}
label={`${group.label} · ${group.rows.length}`}
/>
<div className="divide-y divide-border">
{group.items.map((item) => {
const key = getWorkspaceAttachmentKey(item)
{group.rows.map(({ item, key, url, detail, live }) => {
const label = workspaceAttachmentLabel(item)
const source = getWorkspaceAttachmentSourceLabel(item)
const active = key === activeKey
const detail = referenceDetails?.[referenceKeys[items.indexOf(item)]]
const url = item.url || detail?.url
const live =
detail?.review ??
(active &&
review?.provider === item.provider &&
review.number === item.number &&
(!item.url || review.url === item.url)
? review
: null)
return (
<div
key={key}
@@ -194,7 +180,7 @@ export function WorktreeCardLinkedItemsSection({
onKeyDown={(event) => event.stopPropagation()}
>
<Plus />
{items.length
{rows.length
? translate('workspace.links.manage', 'Manage links…')
: translate('workspace.links.addReference', 'Add reference')}
</Button>
@@ -9,6 +9,10 @@ import type {
WorktreeCardJiraIssueDisplay
} from './worktree-card-meta-types'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type {
WorkspaceReferenceDetails,
WorkspaceReferenceDetailsMap
} from './workspace-reference-details'
type LiveDetails = {
issue?: WorktreeCardIssueDisplay | null
@@ -24,8 +28,11 @@ export function getWorkspaceHoverAttachments(
items: WorkspaceAttachment[]
activeKey: string | null
referenceKeys: string[]
/** Per item: whether it is the card's own review, issue or task, enriched from live details. */
liveMatched: boolean[]
} {
const items = getWorkspaceAttachments(workspace)
const liveMatched: boolean[] = []
const matches = (item: WorkspaceAttachment): boolean => {
if (item.provider === 'github' && item.type === 'issue') {
return item.number === details.issue?.number
@@ -62,6 +69,7 @@ export function getWorkspaceHoverAttachments(
matches(candidate) &&
(!candidate.url || candidate.url === live.url)
).length === 1
liveMatched.push(Boolean(sameReference))
return sameReference
? {
...item,
@@ -88,9 +96,54 @@ export function getWorkspaceHoverAttachments(
return {
items: enriched,
referenceKeys: items.map(getWorkspaceAttachmentKey),
liveMatched,
activeKey:
parseWorkspaceKey(workspace.id)?.type === 'folder' || !activeItem
? null
: getWorkspaceAttachmentKey(activeItem)
}
}
export type WorkspaceLinkedItemRow = {
item: WorkspaceAttachment
key: string
url: string | undefined
detail: WorkspaceReferenceDetails | undefined
/** The review whose live state and checks this row shows, when one applies to it. */
live: WorktreeCardPrDisplay | null
/** The card's own issue or task this row is, with the state the card already loaded. */
liveTask: WorktreeCardIssueDisplay | WorktreeCardLinearIssueDisplay | null
}
/** One row per linked item, with the URL to open and the live review state the row shows. */
export function getWorkspaceLinkedItemRows(
workspace: Worktree,
details: LiveDetails,
referenceDetails: WorkspaceReferenceDetailsMap | undefined
): WorkspaceLinkedItemRow[] {
const { items, activeKey, referenceKeys, liveMatched } = getWorkspaceHoverAttachments(
workspace,
details
)
const review = details.review
return items.map((item, index) => {
const key = getWorkspaceAttachmentKey(item)
const detail = referenceDetails?.[referenceKeys[index]]
const live =
detail?.review ??
(key === activeKey &&
review?.provider === item.provider &&
review.number === item.number &&
(!item.url || review.url === item.url)
? review
: null)
const liveTask = !liveMatched[index]
? null
: item.provider === 'github' && item.type === 'issue'
? (details.issue ?? null)
: item.provider === 'linear'
? (details.linearIssue ?? null)
: null
return { item, key, url: item.url || detail?.url, detail, live, liveTask }
})
}
@@ -3,11 +3,7 @@ import { Plug, ChevronDown, ChevronRight, LoaderCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import {
publishWorkspacePortScanForHost,
scanWorkspacePortsForTarget,
workspacePortScanKeyForTarget
} from '@/lib/workspace-port-actions'
import { refreshWorkspacePortScanForTarget } from '@/lib/workspace-port-scan-refresh'
import { useWorktreeRuntimeTarget } from '@/runtime/use-worktree-runtime-target'
import {
getUnavailableWorkspacePortHosts,
@@ -19,7 +15,6 @@ import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu'
import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy'
import { PortRow, WorkspaceGroupRows } from './ports-status-popover-rows'
import { translate } from '@/i18n/i18n'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
type PortsStatusSegmentProps = {
compact?: boolean
@@ -38,7 +33,6 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
const [open, setOpen] = useState(false)
const [externalOpen, setExternalOpen] = useState(false)
const runtimeTarget = useWorktreeRuntimeTarget(activeWorktreeId)
const scanKey = runtimeTarget ? workspacePortScanKeyForTarget(runtimeTarget) : null
const workspaceGroups = useMemo(() => getWorkspacePortGroups(scan), [scan])
const externalPorts = useMemo(() => getExternalWorkspacePorts(scan), [scan])
@@ -87,36 +81,18 @@ export function PortsStatusSegment({ iconOnly }: PortsStatusSegmentProps): React
return
}
recordFeatureInteraction('ports')
if (!runtimeTarget || !scanKey) {
if (!runtimeTarget) {
return
}
// Why: the 30s background poll is intentionally quiet; opening the
// popover should still collapse that stale window without flashing icons.
const publish = (result: WorkspacePortScanResult): void => {
publishWorkspacePortScanForHost({
scanKey,
scan: result,
replaceWorkspacePortScans,
getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey
})
}
void scanWorkspacePortsForTarget(runtimeTarget)
.then(publish)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
// Why: one dropped scan must not clear the host's last-good ports the
// way the background poll's debounce does not; the failure is still
// recorded so the host is named by the unavailable notice below.
const previous = useAppStore.getState().workspacePortScansByKey[scanKey]
publish({
platform: previous?.platform ?? 'unknown',
scannedAt: Date.now(),
ports: previous?.ports ?? [],
unavailableReason: message || 'Workspace port scan failed.'
})
})
void refreshWorkspacePortScanForTarget({
runtimeTarget,
replaceWorkspacePortScans,
getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey
})
},
[recordFeatureInteraction, runtimeTarget, scanKey, replaceWorkspacePortScans]
[recordFeatureInteraction, runtimeTarget, replaceWorkspacePortScans]
)
return (
+21 -1
View File
@@ -157,6 +157,27 @@
},
"request": {
"pairedServerWorkspace": "This workspace runs on a paired server. Use that server's browser tabs."
},
"newTab": {
"emptyHint": "Search or enter an address above. Sites you visit, dev servers running in this workspace, and its PR and issue will show up here.",
"openServerFailed": "Couldn’t open {{address}}",
"removedFromHistory": "Removed “{{title}}” from history",
"removeSuggestion": "Remove from history",
"state": {
"closed": "Closed",
"draft": "Draft",
"merged": "Merged",
"open": "Open"
},
"suggested": "Suggested",
"thisWorkspace": "This workspace",
"undo": "Undo",
"checks": {
"failing": "Checks failing",
"passing": "Checks passing",
"pending": "Checks pending"
},
"runningElsewhere": "{{count}} running in other workspaces"
}
},
"editor": {
@@ -16976,7 +16997,6 @@
"95af781091": "Send feedback to an agent",
"ac39b9366b": "Send",
"a3508d7e6e": "{{value0}} annotation{{value1}} ready. Select another element or copy all feedback.",
"f796c774a4": "Type a URL above to start browsing.",
"366bf5d62c": "New Tab",
"1c78adc73d": "Open Externally",
"da68d35f7b": "Open failed page in default browser",
-1
View File
@@ -16656,7 +16656,6 @@
"95af781091": "Enviar comentarios a un agente",
"ac39b9366b": "Enviar",
"a3508d7e6e": "{{value0}} anotación{{value1}} listo. Seleccione otro elemento o copie todos los comentarios.",
"f796c774a4": "Escriba una URL arriba para comenzar a navegar.",
"366bf5d62c": "Nueva pestaña",
"1c78adc73d": "Abrir externamente",
"da68d35f7b": "Abrir página fallida en el navegador predeterminado",
-1
View File
@@ -16769,7 +16769,6 @@
"95af781091": "Envoyer un retour à un agent",
"ac39b9366b": "Envoyer",
"a3508d7e6e": "{{value0}} annotation{{value1}} prête. Sélectionnez un autre élément ou copiez tous les retours.",
"f796c774a4": "Saisissez une URL ci-dessus pour commencer à naviguer.",
"366bf5d62c": "Nouvel onglet",
"1c78adc73d": "Ouvrir en externe",
"da68d35f7b": "Ouvrir la page en erreur dans le navigateur par défaut",
-1
View File
@@ -16661,7 +16661,6 @@
"95af781091": "Agent にフィードバックを送信する",
"ac39b9366b": "送信",
"a3508d7e6e": "{{value0}} アノテーション{{value1}} が準備されました。別の要素を選択するか、すべてのフィードバックをコピーします。",
"f796c774a4": "上に URL を入力して閲覧を開始します。",
"366bf5d62c": "新規タブ",
"1c78adc73d": "外部で開く",
"da68d35f7b": "失敗したページをデフォルトのブラウザで開く",
-1
View File
@@ -16661,7 +16661,6 @@
"95af781091": "agent에게 피드백 보내기",
"ac39b9366b": "보내기",
"a3508d7e6e": "{{value0}} 주석{{value1}}이 준비되었습니다. 다른 요소를 선택하거나 모든 피드백을 복사하세요.",
"f796c774a4": "탐색을 시작하려면 위에 URL을 입력하세요.",
"366bf5d62c": "새 탭",
"1c78adc73d": "외부에서 열기",
"da68d35f7b": "기본 브라우저에서 실패한 페이지 열기",
-1
View File
@@ -16337,7 +16337,6 @@
"95af781091": "Enviar feedback para um agente",
"ac39b9366b": "Enviar",
"a3508d7e6e": "{{value0}} anotação{{value1}} pronta. Selecione outro elemento ou copie todo o feedback.",
"f796c774a4": "Digite uma URL acima para começar a navegar.",
"366bf5d62c": "Nova Aba",
"1c78adc73d": "Abrir Externamente",
"da68d35f7b": "Abrir a página com falha no navegador padrão",
-1
View File
@@ -16661,7 +16661,6 @@
"95af781091": "向智能体发送反馈",
"ac39b9366b": "发送",
"a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。",
"f796c774a4": "在上面输入 URL 即可开始浏览。",
"366bf5d62c": "新标签",
"1c78adc73d": "在外部打开",
"da68d35f7b": "在默认浏览器中打开失败页面",
@@ -3,6 +3,7 @@ import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-type
import {
matchBrowserHistory,
prepareBrowserHistoryEntries,
rankBrowserTopSites,
type BrowserHistoryMatchTier
} from './browser-history-match'
@@ -212,3 +213,26 @@ describe('browser history match', () => {
}
})
})
describe('rankBrowserTopSites', () => {
it('ranks pages by visits and recency, leaving out dev servers and non-web pages', () => {
const ranked = rankBrowserTopSites({
prepared: prepareBrowserHistoryEntries([
entry({ url: 'https://react.dev/', visitCount: 3, lastVisitedAt: NOW - 48 * HOUR }),
entry({ url: 'https://github.com/acme/orca', visitCount: 2, lastVisitedAt: NOW }),
entry({ url: 'http://localhost:5173/', visitCount: 40 }),
entry({ url: 'http://web.orca.localhost:3000/', visitCount: 40 }),
entry({ url: 'file:///tmp/notes.html', visitCount: 40 }),
entry({ url: 'https://example.com/', visitCount: 1, lastVisitedAt: NOW - 48 * HOUR })
]),
limit: 2,
now: NOW
})
// github: 2 visits + a fresh-visit bonus beats react's 3 stale visits.
expect(ranked.map((page) => page.url)).toEqual([
'https://github.com/acme/orca',
'https://react.dev/'
])
})
})
+51 -9
View File
@@ -2,6 +2,10 @@
// bar and the new-tab omnibox. Pure: no store, no React.
import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types'
import {
LOOPBACK_LOCALHOST_HOSTS,
normalizeLocalhostHostname
} from '../../../shared/localhost-worktree-labels'
import { formatBrowserPaletteUrl } from './browser-palette-search'
/** Compared before frecency, so "the site you meant" always beats "appears somewhere". */
@@ -73,6 +77,15 @@ export function prepareBrowserHistoryEntries(
return prepared
}
function frecencyScore(prepared: PreparedBrowserHistoryEntry, now: number): number {
const ageHours = (now - prepared.entry.lastVisitedAt) / (1000 * 60 * 60)
// Clamped both ends: a future lastVisitedAt must not buy more than a fresh visit.
return (
prepared.frecencyBase +
Math.min(RECENCY_BONUS_HOURS, Math.max(0, RECENCY_BONUS_HOURS - ageHours))
)
}
function matchTier(
prepared: PreparedBrowserHistoryEntry,
lowerQuery: string
@@ -118,15 +131,7 @@ export function matchBrowserHistory({
if (tier === null || (tier === 'url-tail' && !includeUrlTail)) {
continue
}
const ageHours = (now - candidate.entry.lastVisitedAt) / (1000 * 60 * 60)
matches.push({
entry: candidate.entry,
tier,
// Clamped both ends: a future lastVisitedAt must not buy more than a fresh visit.
score:
candidate.frecencyBase +
Math.min(RECENCY_BONUS_HOURS, Math.max(0, RECENCY_BONUS_HOURS - ageHours))
})
matches.push({ entry: candidate.entry, tier, score: frecencyScore(candidate, now) })
}
if (matches.length === 0) {
return NO_MATCHES
@@ -142,3 +147,40 @@ export function matchBrowserHistory({
)
return matches.slice(0, limit)
}
// Why loopback is out: dev servers come and go, so the new-tab page lists the live ones instead.
function isTopSiteCandidate(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
const hostname = normalizeLocalhostHostname(parsed.hostname)
return !LOOPBACK_LOCALHOST_HOSTS.has(hostname) && !hostname.endsWith('.localhost')
} catch {
return false
}
}
/** New-tab tiles, one per page as Chrome does, so removing a tile removes exactly that page. */
export function rankBrowserTopSites({
limit,
now = Date.now(),
prepared
}: {
limit: number
now?: number
prepared: readonly PreparedBrowserHistoryEntry[]
}): readonly BrowserHistoryEntry[] {
return prepared
.filter((candidate) => isTopSiteCandidate(candidate.entry.url))
.map((candidate) => ({ entry: candidate.entry, score: frecencyScore(candidate, now) }))
.sort(
(a, b) =>
b.score - a.score ||
b.entry.lastVisitedAt - a.entry.lastVisitedAt ||
a.entry.normalizedUrl.localeCompare(b.entry.normalizedUrl)
)
.slice(0, limit)
.map((ranked) => ranked.entry)
}
+22 -9
View File
@@ -97,6 +97,23 @@ export function goToWorkspacePortOwner(port: WorkspacePort): boolean {
return Boolean(worktreeId && activateAndRevealWorktree(worktreeId))
}
/** The URL a port opens at: its labelled localhost name when one is set up locally, else its raw URL. */
export async function resolveWorkspacePortBrowserUrl(args: {
port: WorkspacePort
runtimeTarget: RuntimeClientTarget
localhostLabelRoute?: LocalhostWorktreeLabelRoute | null
}): Promise<string> {
const rawUrl = browserUrlForPort(args.port)
if (args.runtimeTarget.kind !== 'local' || !args.localhostLabelRoute) {
return rawUrl
}
try {
return (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url
} catch {
return rawUrl
}
}
export async function openWorkspacePortInBrowser(args: {
port: WorkspacePort
activeWorktreeId?: string | null
@@ -109,15 +126,11 @@ export async function openWorkspacePortInBrowser(args: {
if (!args.runtimeTarget) {
return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON }
}
const rawUrl = browserUrlForPort(args.port)
let url = rawUrl
if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) {
try {
url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url
} catch {
url = rawUrl
}
}
const url = await resolveWorkspacePortBrowserUrl({
port: args.port,
runtimeTarget: args.runtimeTarget,
localhostLabelRoute: args.localhostLabelRoute
})
if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') {
try {
await window.api.shell.openUrl(url)
@@ -8,6 +8,8 @@ import type {
WorkspacePort,
WorkspacePortHostScanResult,
WorkspacePortKillResult,
WorkspacePortPageProbeRequest,
WorkspacePortPageProbeResult,
WorkspacePortScanResult
} from '../../../shared/workspace-ports'
import { normalizeExecutionHostId } from '../../../shared/execution-host'
@@ -213,3 +215,29 @@ export async function killWorkspacePortOnExecutionHost(
throw error
}
}
/**
* Whether a scanned port serves a web page, asked of the host the port is on. `null` when that
* host's Orca predates the probe, so callers fall back to what the scan alone knows.
*/
export async function probeWorkspacePortPageForTarget(
target: RuntimeClientTarget,
request: WorkspacePortPageProbeRequest
): Promise<WorkspacePortPageProbeResult | null> {
if (target.kind === 'local') {
return window.api.workspacePorts.probePage(request)
}
try {
return await callRuntimeRpc<WorkspacePortPageProbeResult>(
target,
'workspacePorts.probePage',
request,
{ timeoutMs: WORKSPACE_PORT_RPC_TIMEOUT_MS }
)
} catch (error) {
if (isMethodNotFound(error)) {
return null
}
throw error
}
}
@@ -0,0 +1,32 @@
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { WorkspacePortScanResult } from '../../../shared/workspace-ports'
import {
publishWorkspacePortScanForHost,
scanWorkspacePortsForTarget,
workspacePortScanKeyForTarget,
type WorkspacePortScanPublisher
} from './workspace-port-actions'
/** Re-scans one host now. A failed scan keeps the host's last-good ports and records the failure. */
export async function refreshWorkspacePortScanForTarget(
args: WorkspacePortScanPublisher & { runtimeTarget: RuntimeClientTarget }
): Promise<void> {
const scanKey = workspacePortScanKeyForTarget(args.runtimeTarget)
const publish = (scan: WorkspacePortScanResult): void => {
publishWorkspacePortScanForHost({ ...args, scanKey, scan })
}
try {
publish(await scanWorkspacePortsForTarget(args.runtimeTarget))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Why: one dropped scan must not clear the host's last-good ports the way the background
// poll's debounce does not; the failure is still recorded so the host is named as unavailable.
const previous = args.getWorkspacePortScansByKey()[scanKey]
publish({
platform: previous?.platform ?? 'unknown',
scannedAt: Date.now(),
ports: previous?.ports ?? [],
unavailableReason: message || 'Workspace port scan failed.'
})
}
}
@@ -311,6 +311,25 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBeNull()
})
it('puts a removed history page back as it was on Undo, unless a revisit already re-created it', () => {
const store = createTestStore()
store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
const [visited] = store.getState().browserUrlHistory
const removed = store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
expect(removed).toBe(visited)
expect(store.getState().browserUrlHistory).toEqual([])
store.getState().restoreBrowserHistoryEntry(removed!)
expect(store.getState().browserUrlHistory).toEqual([visited])
store.getState().removeBrowserHistoryEntry(visited!.normalizedUrl)
store.getState().addBrowserHistoryEntry('https://example.com', 'Example')
const revisited = store.getState().browserUrlHistory
store.getState().restoreBrowserHistoryEntry(removed!)
expect(store.getState().browserUrlHistory).toBe(revisited)
})
it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
@@ -17,10 +17,14 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
export function createBrowserHistoryActions(
set: BrowserSliceSet,
_get: BrowserSliceGet
get: BrowserSliceGet
): Pick<
BrowserSlice,
'recordWorkspaceDocVisit' | 'addBrowserHistoryEntry' | 'clearBrowserHistory'
| 'recordWorkspaceDocVisit'
| 'addBrowserHistoryEntry'
| 'removeBrowserHistoryEntry'
| 'restoreBrowserHistoryEntry'
| 'clearBrowserHistory'
> {
return {
recordWorkspaceDocVisit: (docLocation, title, options) => {
@@ -110,6 +114,26 @@ export function createBrowserHistoryActions(
})
},
removeBrowserHistoryEntry: (normalizedUrl) => {
const removed =
get().browserUrlHistory.find((entry) => entry.normalizedUrl === normalizedUrl) ?? null
if (removed) {
set((s) => ({
browserUrlHistory: s.browserUrlHistory.filter((entry) => entry !== removed)
}))
}
return removed
},
restoreBrowserHistoryEntry: (entry) => {
set((s) =>
// A revisit between remove and Undo already re-created the row; keep the live one.
s.browserUrlHistory.some((existing) => existing.normalizedUrl === entry.normalizedUrl)
? s
: { browserUrlHistory: [entry, ...s.browserUrlHistory] }
)
},
// One clear for both sources: the dropdown presents them as one history.
clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] })
}
@@ -260,6 +260,9 @@ export type BrowserSlice = {
clearDefaultSessionCookies: () => Promise<boolean>
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string, faviconUrl?: string | null) => void
/** Returns the removed entry so an Undo can put it back with its visit count. */
removeBrowserHistoryEntry: (normalizedUrl: string) => BrowserHistoryEntry | null
restoreBrowserHistoryEntry: (entry: BrowserHistoryEntry) => void
workspaceDocHistory: WorkspaceDocHistoryEntry[]
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
recordWorkspaceDocVisit: (
@@ -17,6 +17,7 @@ export function createWebWorkspacePortsApi(): Partial<PreloadApi> {
ok: false,
reason: 'Workspace port management is unavailable for browser-local workspaces.'
}),
probePage: () => Promise.resolve({ kind: 'not-page' }),
onAdvertisedUrlChanged: () => noopUnsubscribe
}
}
+2
View File
@@ -624,6 +624,7 @@ import {
import {
WorkspacePortKillHostParams,
WorkspacePortKillParams,
WorkspacePortProbePageParams,
WorkspacePortScanHostParams,
WorkspacePortScanParams
} from './workspace-ports-params'
@@ -1403,6 +1404,7 @@ export const RPC_PARAMS_BY_METHOD = {
'updater.install': null,
'workspacePorts.kill': WorkspacePortKillParams,
'workspacePorts.killHost': WorkspacePortKillHostParams,
'workspacePorts.probePage': WorkspacePortProbePageParams,
'workspacePorts.scan': WorkspacePortScanParams,
'workspacePorts.scanHost': WorkspacePortScanHostParams,
'worktree.activate': WorktreeActivate,
@@ -11,6 +11,12 @@ export const WorkspacePortKillParams = z.object({
port: requiredNumber('Missing port')
})
export const WorkspacePortProbePageParams = z.object({
host: requiredString('Missing host'),
port: requiredNumber('Missing port'),
protocol: z.enum(['http', 'https'])
})
export const WorkspacePortScanHostParams = z.object({
worktree: requiredString('Missing worktree selector')
})
+11
View File
@@ -54,6 +54,17 @@ export type WorkspacePortAdvertisedUrlChangedEvent = {
port: number
}
/** Asks whether a scanned listener serves a web page. Loopback only; the scan supplies host and port. */
export type WorkspacePortPageProbeRequest = {
host: string
port: number
protocol: 'http' | 'https'
}
export type WorkspacePortPageProbeResult =
| { kind: 'page'; title: string | null; faviconDataUrl: string | null }
| { kind: 'not-page' }
export type WorkspacePortKillRequest = {
repoId?: string
pid: number