diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.tsx
index 6abac438baa..18483ddb9fc 100644
--- a/src/renderer/src/components/right-sidebar/PortsPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/PortsPanel.tsx
@@ -6,7 +6,11 @@ import { translate } from '@/i18n/i18n'
import { LocalWorkspacePortsPanel } from './local-workspace-ports-panel'
import { SshPortsPanel } from './ssh-ports-panel'
import { HostScopedPortsPanel } from './host-scoped-ports-panel'
-import { getPortsPanelOwnerKey, portsPanelHostForOwnerKey } from './ports-panel-host'
+import {
+ getPortsPanelOwnerKey,
+ portsPanelForwardTargetId,
+ portsPanelHostForOwnerKey
+} from './ports-panel-host'
export { getLocalWorkspacePortSections } from './local-workspace-port-sections'
export {
@@ -20,6 +24,9 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
const workspaceId = activeWorktree?.id ?? null
const ownerKey = useAppStore((state) => getPortsPanelOwnerKey(state, workspaceId))
const host = useMemo(() => portsPanelHostForOwnerKey(ownerKey), [ownerKey])
+ const sshForwardTargetId = useAppStore((state) =>
+ portsPanelForwardTargetId(host, state.runtimeEnvironments)
+ )
const scopedHost = useMemo(
() =>
host.kind === 'host-scoped' && workspaceId
@@ -38,7 +45,13 @@ export default function PortsPanel({ isVisible }: { isVisible: boolean }): React
case 'direct-ssh':
return
case 'endpoint':
- return
+ return (
+
+ )
case 'host-scoped':
case 'unknown':
return (
diff --git a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx
index ab32cc0073d..84d541d8a4e 100644
--- a/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx
+++ b/src/renderer/src/components/right-sidebar/local-workspace-ports-panel.tsx
@@ -25,15 +25,19 @@ import {
} from './local-workspace-port-sections'
import { LocalPortSection } from './local-port-section'
import { LocalPortDetailsDialog } from './local-port-details-dialog'
+import { SshForwardedPortsSection } from './ssh-forwarded-ports-section'
/** Right-sidebar Ports panel for a workspace that runs on an endpoint's own machine. */
export function LocalWorkspacePortsPanel({
isVisible,
- runtimeTarget
+ runtimeTarget,
+ sshForwardTargetId = null
}: {
isVisible: boolean
/** The endpoint that owns the active workspace, even if the sidebar is focused elsewhere. */
runtimeTarget: RuntimeClientTarget | null
+ /** A managed server's SSH host, whose connection carries this client's forwards. */
+ sshForwardTargetId?: string | null
}): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
@@ -227,6 +231,8 @@ export function LocalWorkspacePortsPanel({
+ {sshForwardTargetId && }
+
{displayScan?.unavailableReason && (
{translate(
diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts
index 1e5b31fb54f..1b503417315 100644
--- a/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts
+++ b/src/renderer/src/components/right-sidebar/ports-panel-host.test.ts
@@ -6,6 +6,7 @@ import type {
} from '@/lib/worktree-operation-route'
import {
getPortsPanelOwnerKey,
+ portsPanelForwardTargetId,
portsPanelHostForOwnerKey,
portsPanelReachesSshHost,
type PortsPanelHost
@@ -187,3 +188,32 @@ describe('Ports tab visibility', () => {
expect(portsPanelReachesSshHost(host, environments)).toBe(true)
})
})
+
+describe('Ports panel forwards', () => {
+ const ports = { localPort: 41_000, remotePort: 6768 }
+ const environments = [
+ { id: 'env-paired' },
+ { id: 'env-ssh', orcadDeployment: { sshTargetId: 'box', sshTargetGeneration: 1, ...ports } },
+ { id: 'env-wsl', orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', ...ports } }
+ ]
+ const onServer = (environmentId: string): PortsPanelHost => ({
+ kind: 'endpoint',
+ target: { kind: 'environment', environmentId }
+ })
+
+ // #29153: a moved host's workspace is owned by its server, yet forwards still ride its SSH host.
+ it.each<[string, PortsPanelHost, string | null]>([
+ ['a workspace on a managed server reached over SSH', onServer('env-ssh'), 'box'],
+ ['a workspace on a managed WSL server', onServer('env-wsl'), null],
+ ['a workspace on a paired server', onServer('env-paired'), null],
+ ['a workspace on an unknown server', onServer('env-gone'), null],
+ ['a local workspace', { kind: 'endpoint', target: { kind: 'local' } }, null],
+ [
+ 'a direct SSH workspace, which lists its own forwards',
+ { kind: 'direct-ssh', connectionId: 'box' },
+ null
+ ]
+ ])('%s', (_name, host, targetId) => {
+ expect(portsPanelForwardTargetId(host, environments)).toBe(targetId)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ports-panel-host.ts b/src/renderer/src/components/right-sidebar/ports-panel-host.ts
index 91853b59eb8..616386c1daf 100644
--- a/src/renderer/src/components/right-sidebar/ports-panel-host.ts
+++ b/src/renderer/src/components/right-sidebar/ports-panel-host.ts
@@ -2,6 +2,10 @@ import { resolveOwner } from '@/lib/resolve-owner'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
import { hostRouteForAuthority, type HostRoute } from '@/runtime/runtime-client-target'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
+import {
+ getOrcadSshDeployment,
+ type OrcadDeploymentLink
+} from '../../../../shared/runtime-environments'
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
import {
authorityKey,
@@ -77,3 +81,20 @@ export function portsPanelReachesSshHost(
return false
}
}
+
+/**
+ * The SSH host whose connection carries this client's forwards for a managed server's workspace.
+ * Why: the server scans its own ports, but a forward listens on this machine, so it rides the SSH
+ * connection under the server's tunnel, as it did before the host moved (#29153).
+ */
+export function portsPanelForwardTargetId(
+ host: PortsPanelHost,
+ environments: readonly { id: string; orcadDeployment?: OrcadDeploymentLink }[]
+): string | null {
+ if (host.kind !== 'endpoint' || host.target.kind !== 'environment') {
+ return null
+ }
+ const { environmentId } = host.target
+ const environment = environments.find((candidate) => candidate.id === environmentId)
+ return (environment && getOrcadSshDeployment(environment)?.sshTargetId) || null
+}
diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx
new file mode 100644
index 00000000000..48d4c443846
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.test.tsx
@@ -0,0 +1,65 @@
+// @vitest-environment happy-dom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { PortForwardEntry } from '../../../../shared/ssh-types'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { useAppStore } from '@/store'
+import { SshForwardedPortsSection } from './ssh-forwarded-ports-section'
+
+const initialState = useAppStore.getInitialState()
+
+const forward: PortForwardEntry = {
+ id: 'fwd-1',
+ connectionId: 'box',
+ localPort: 3000,
+ remoteHost: 'localhost',
+ remotePort: 3000,
+ label: 'web'
+}
+const remapped: PortForwardEntry = { ...forward, id: 'fwd-2', localPort: 13000, label: 'remapped' }
+
+function renderSection(status: 'connected' | 'disconnected', forwards: PortForwardEntry[]) {
+ useAppStore.setState({
+ portForwardsByConnection: { box: forwards },
+ sshConnectionStates: new Map([
+ ['box', { targetId: 'box', status, error: null, reconnectAttempt: 0 }]
+ ])
+ })
+ return render(
+
+
+
+ )
+}
+
+afterEach(() => {
+ cleanup()
+ useAppStore.setState(initialState, true)
+})
+
+describe('SSH forwards for a managed server workspace', () => {
+ it("lists the host's saved forwards", () => {
+ renderSection('connected', [forward])
+ expect(screen.getByText('web')).toBeTruthy()
+ })
+
+ it('opens the forward dialog for the SSH host under the server', () => {
+ renderSection('connected', [])
+ fireEvent.click(screen.getByRole('button', { name: /Add/ }))
+ expect(screen.getByRole('dialog')).toBeTruthy()
+ })
+
+ it('cannot add a forward while the SSH host is down', () => {
+ renderSection('disconnected', [])
+ expect(screen.getByRole('button', { name: /Add/ }).hasAttribute('disabled')).toBe(true)
+ })
+
+ it("opens a forward's local address on this machine, where it listens", () => {
+ const openUrl = vi.fn(async () => undefined)
+ Object.defineProperty(window, 'api', { value: { shell: { openUrl } }, configurable: true })
+ renderSection('connected', [remapped])
+ fireEvent.click(screen.getByTitle(/Open in Browser/))
+ expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:13000')
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx
new file mode 100644
index 00000000000..b7a4eda8352
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/ssh-forwarded-ports-section.tsx
@@ -0,0 +1,87 @@
+import React, { useState } from 'react'
+import { ChevronRight, Plus } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import type { PortForwardEntry } from '../../../../shared/ssh-types'
+import { translate } from '@/i18n/i18n'
+import { SshForwardedPortRow } from './ssh-forwarded-port-row'
+import { SshPortForwardDialog, type PortForwardDialogState } from './ssh-port-forward-dialog'
+import { browserUrlForPortForwardEntry } from '@/lib/workspace-port-urls'
+
+const NO_FORWARDS: readonly PortForwardEntry[] = []
+
+/** This client's forwards through an SSH host whose ports another endpoint scans. */
+export function SshForwardedPortsSection({
+ connectionId
+}: {
+ connectionId: string
+}): React.JSX.Element {
+ const forwards = useAppStore((s) => s.portForwardsByConnection[connectionId] ?? NO_FORWARDS)
+ const connected = useAppStore(
+ (s) => s.sshConnectionStates.get(connectionId)?.status === 'connected'
+ )
+ const [collapsed, setCollapsed] = useState(false)
+ const [dialogState, setDialogState] = useState({ mode: 'closed' })
+
+ return (
+
+
+
+
+
+ {!collapsed &&
+ (forwards.length > 0 ? (
+ forwards.map((entry) => (
+ setDialogState({ mode: 'edit', entry })}
+ // Why the system browser: the forward listens on this machine, while the workspace's
+ // Orca browser may be the server's or routed through the SSH host.
+ onOpenInBrowser={() =>
+ void window.api.shell.openUrl(browserUrlForPortForwardEntry(entry))
+ }
+ />
+ ))
+ ) : (
+
+ {translate(
+ 'auto.components.right.sidebar.PortsPanel.04efd3dad4',
+ 'Forward a port to access remote services on your local machine.'
+ )}
+