feat: surface port forwarding controls and browser navigation (#2383)

This commit is contained in:
Carlos Florêncio
2026-08-08 13:49:58 +01:00
committed by GitHub
parent cba3838c89
commit de4e4161ed
56 changed files with 2852 additions and 126 deletions
@@ -139,6 +139,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
api.GET("/task-sessions/:id/turns", h.httpListSessionTurns)
api.POST("/tasks", h.httpCreateTask)
api.PATCH("/tasks/:id", h.httpUpdateTask)
api.PATCH("/tasks/:id/port-forwarding", h.httpUpdateTaskPortForwarding)
api.POST("/tasks/:id/detach", h.httpDetachTask)
api.POST("/tasks/:id/workspace-sources", h.httpAttachWorkspaceSources)
api.PATCH("/tasks/:id/repositories/:repo_id", h.httpUpdateTaskRepository)
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@@ -1322,6 +1323,33 @@ type httpUpdateTaskRequest struct {
ParentID *string `json:"parent_id,omitempty"`
}
type httpUpdateTaskPortForwardingRequest struct {
Enabled *bool `json:"enabled"`
}
func (h *TaskHandlers) httpUpdateTaskPortForwarding(c *gin.Context) {
var body httpUpdateTaskPortForwardingRequest
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled must be a boolean"})
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled must be a boolean"})
return
}
task, err := h.service.UpdateTaskMetadata(c.Request.Context(), c.Param("id"), map[string]interface{}{
models.MetaKeyPortForwardingEnabled: *body.Enabled,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not updated")
return
}
c.JSON(http.StatusOK, dto.FromTask(task))
}
func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
var body httpUpdateTaskRequest
if err := c.ShouldBindJSON(&body); err != nil {
@@ -0,0 +1,127 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
)
type portForwardingHandlerRepo struct {
mockRepository
task *models.Task
updatedTask *models.Task
updateCalls int
}
func (r *portForwardingHandlerRepo) GetTask(_ context.Context, _ string) (*models.Task, error) {
if r.task == nil {
return nil, taskrepo.ErrTaskNotFound
}
return r.task, nil
}
func (r *portForwardingHandlerRepo) UpdateTask(_ context.Context, task *models.Task) error {
r.updatedTask = task
r.updateCalls++
return nil
}
func newPortForwardingHandler(t *testing.T, repo *portForwardingHandlerRepo) *TaskHandlers {
t.Helper()
taskService := service.NewService(service.Repos{
Tasks: repo,
Workflows: repo,
Workspaces: repo,
}, nil, newTestLogger(t), service.RepositoryDiscoveryConfig{})
return &TaskHandlers{service: taskService, logger: newTestLogger(t)}
}
func TestHTTPUpdateTaskPortForwardingMergesMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &portForwardingHandlerRepo{task: &models.Task{
ID: "task-port-forwarding",
WorkspaceID: "workspace-1",
Metadata: map[string]interface{}{
"unrelated": "preserve",
models.MetaKeyPortForwardingEnabled: false,
},
}}
handlers := newPortForwardingHandler(t, repo)
router := gin.New()
handlers.registerHTTP(router)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/tasks/task-port-forwarding/port-forwarding", strings.NewReader(`{"enabled":true}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
assert.Equal(t, 1, repo.updateCalls)
require.NotNil(t, repo.updatedTask)
assert.Equal(t, "preserve", repo.updatedTask.Metadata["unrelated"])
assert.Equal(t, true, repo.updatedTask.Metadata[models.MetaKeyPortForwardingEnabled])
var response dto.TaskDTO
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &response))
assert.Equal(t, true, response.Metadata[models.MetaKeyPortForwardingEnabled])
}
func TestHTTPUpdateTaskPortForwardingRejectsInvalidBodies(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "missing enabled", body: `{}`},
{name: "null enabled", body: `{"enabled":null}`},
{name: "non boolean enabled", body: `{"enabled":"true"}`},
{name: "unknown field", body: `{"enabled":true,"unexpected":true}`},
{name: "malformed json", body: `{"enabled":`},
{name: "trailing json value", body: `{"enabled":true} {"enabled":false}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &portForwardingHandlerRepo{task: &models.Task{ID: "task-port-forwarding"}}
handlers := newPortForwardingHandler(t, repo)
router := gin.New()
handlers.registerHTTP(router)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/tasks/task-port-forwarding/port-forwarding", strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assert.Equal(t, http.StatusBadRequest, res.Code)
assert.Zero(t, repo.updateCalls)
})
}
}
func TestHTTPUpdateTaskPortForwardingReturnsNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &portForwardingHandlerRepo{}
handlers := newPortForwardingHandler(t, repo)
router := gin.New()
handlers.registerHTTP(router)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/tasks/missing-task/port-forwarding", strings.NewReader(`{"enabled":true}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assert.Equal(t, http.StatusNotFound, res.Code)
assert.Zero(t, repo.updateCalls)
}
@@ -122,6 +122,9 @@ const (
// MetaKeyAgentTitleOwnerSessionID records the one session that atomically
// claimed the first-turn title handoff for a pending task.
MetaKeyAgentTitleOwnerSessionID = "agent_title_owner_session_id"
// MetaKeyPortForwardingEnabled controls whether a task exposes its
// session port-forwarding controls in the task UI.
MetaKeyPortForwardingEnabled = "port_forwarding_enabled"
)
// IsAgentTitlePending reports whether task metadata contains the durable
@@ -0,0 +1,96 @@
package service
import (
"context"
"testing"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
)
func TestUpdateTaskMetadataPublishesMergedTaskUpdatedForPortForwarding(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-port", Name: "Port workspace"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-port", WorkspaceID: "ws-port", Name: "Port workflow"}); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "task-port",
WorkspaceID: "ws-port",
WorkflowID: "wf-port",
WorkflowStepID: "step-port",
Title: "Port task",
Priority: "medium",
Metadata: map[string]interface{}{"unrelated": "preserve", models.MetaKeyPortForwardingEnabled: false},
}); err != nil {
t.Fatalf("CreateTask: %v", err)
}
eventBus.ClearEvents()
updated, err := svc.UpdateTaskMetadata(ctx, "task-port", map[string]interface{}{
models.MetaKeyPortForwardingEnabled: true,
})
if err != nil {
t.Fatalf("UpdateTaskMetadata: %v", err)
}
if got := updated.Metadata[models.MetaKeyPortForwardingEnabled]; got != true {
t.Fatalf("updated preference = %#v, want true", got)
}
if got := updated.Metadata["unrelated"]; got != "preserve" {
t.Fatalf("updated unrelated metadata = %#v, want preserve", got)
}
persisted, err := repo.GetTask(ctx, "task-port")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if got := persisted.Metadata[models.MetaKeyPortForwardingEnabled]; got != true {
t.Fatalf("persisted preference = %#v, want true", got)
}
if got := persisted.Metadata["unrelated"]; got != "preserve" {
t.Fatalf("persisted unrelated metadata = %#v, want preserve", got)
}
published := eventBus.GetPublishedEvents()
if len(published) != 1 {
t.Fatalf("published events = %d, want 1", len(published))
}
if published[0].Type != events.TaskUpdated {
t.Fatalf("event type = %q, want %q", published[0].Type, events.TaskUpdated)
}
data, ok := published[0].Data.(map[string]interface{})
if !ok {
t.Fatalf("event data type = %T, want map[string]interface{}", published[0].Data)
}
metadata, ok := data["metadata"].(map[string]interface{})
if !ok {
t.Fatalf("event metadata type = %T, want map[string]interface{}", data["metadata"])
}
if got := metadata[models.MetaKeyPortForwardingEnabled]; got != true {
t.Fatalf("event preference = %#v, want true", got)
}
if got := metadata["unrelated"]; got != "preserve" {
t.Fatalf("event unrelated metadata = %#v, want preserve", got)
}
eventBus.ClearEvents()
updated, err = svc.UpdateTaskMetadata(ctx, "task-port", map[string]interface{}{
models.MetaKeyPortForwardingEnabled: false,
})
if err != nil {
t.Fatalf("UpdateTaskMetadata disable: %v", err)
}
if got := updated.Metadata[models.MetaKeyPortForwardingEnabled]; got != false {
t.Fatalf("disabled preference = %#v, want false", got)
}
if got := updated.Metadata["unrelated"]; got != "preserve" {
t.Fatalf("disabled unrelated metadata = %#v, want preserve", got)
}
if published := eventBus.GetPublishedEvents(); len(published) != 1 || published[0].Type != events.TaskUpdated {
t.Fatalf("disabled update events = %#v, want one task.updated event", published)
}
}
@@ -346,6 +346,9 @@ func (s *Service) updateTaskStateIfSessionState(
// UpdateTaskMetadata updates only the metadata of a task (merges with existing)
func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) {
if err := s.authorizeTaskID(ctx, id); err != nil {
return nil, err
}
task, err := s.tasks.GetTask(ctx, id)
if err != nil {
return nil, err
@@ -365,6 +368,7 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata ma
return nil, err
}
s.PublishTaskUpdated(ctx, task)
s.logger.Debug("task metadata updated", zap.String("task_id", id), zap.Any("metadata", metadata))
return task, nil
}
+6 -1
View File
@@ -68,6 +68,11 @@ function useBrowserPanelUrl(initialUrl: string, useProxy: boolean) {
const [refreshKey, setRefreshKey] = useState(0);
const [showIframe, setShowIframe] = useState(false);
useEffect(() => {
setUserUrl(initialUrl);
setUrlDraft(initialUrl);
}, [initialUrl]);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const devProcessId = useAppStore((state) =>
activeSessionId ? state.processes.devProcessBySessionId[activeSessionId] : undefined,
@@ -148,7 +153,7 @@ export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelP
const showInspect = url.canProxy;
return (
<PanelRoot>
<PanelRoot data-testid="browser-panel">
<PanelHeaderBar>
<Input
value={url.displayDraft}
@@ -207,6 +207,50 @@ describe("AddPanelMenuItems — plugin task panels (AC1)", () => {
});
});
describe("AddPanelMenuItems — port forwarding preference", () => {
function portForwardingState(
overrides: Partial<NonNullable<AddPanelMenuState["portForwarding"]>> = {},
): NonNullable<AddPanelMenuState["portForwarding"]> {
return {
enabled: false,
canToggle: true,
isUpdating: false,
dialogOpen: false,
setDialogOpen: vi.fn(),
togglePortForwarding: vi.fn(),
...overrides,
};
}
it("renders a checkable row and opens after enabling", () => {
const togglePortForwarding = vi.fn();
renderMenu({
taskId: null,
portForwarding: portForwardingState({ togglePortForwarding }),
});
const row = screen.getByTestId("port-forwarding-menu-item");
expect(row.getAttribute("role")).toBe("menuitemcheckbox");
expect(row.getAttribute("aria-checked")).toBe("false");
fireEvent.click(row);
expect(togglePortForwarding).toHaveBeenCalledWith({ openDialogOnEnable: true });
});
it("disables the row when the task session is not ready", () => {
const togglePortForwarding = vi.fn();
renderMenu({
taskId: null,
portForwarding: portForwardingState({ canToggle: false, togglePortForwarding }),
});
const row = screen.getByTestId("port-forwarding-menu-item");
expect(row.getAttribute("data-disabled")).toBe("");
fireEvent.click(row);
expect(togglePortForwarding).not.toHaveBeenCalled();
});
});
describe("AddPanelMenuItems — Todos", () => {
it("renders an always-available Todos row that opens/focuses the panel in the invoking group", () => {
renderMenu();
@@ -8,9 +8,11 @@ import {
IconGitBranch,
IconGitPullRequest,
IconListCheck,
IconNetwork,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -26,6 +28,7 @@ import { mrTaskKey } from "@/components/gitlab/mr-detail-panel";
import { RepositoryScriptsMenuItems } from "./repository-scripts-menu";
import { SessionReopenMenuItems } from "./session-reopen-menu";
import { TerminalReopenMenuItems } from "./terminal-reopen-menu";
import type { PortForwardingVisibility } from "./port-forwarding-visibility-provider";
export type AddPanelMenuState = {
taskId: string | null;
@@ -35,6 +38,7 @@ export type AddPanelMenuState = {
/** All PRs linked to the task; multi-repo tasks render one menu item per PR. */
prs: TaskPR[];
mrs: TaskMR[];
portForwarding?: PortForwardingVisibility;
};
type AddPanelMenuItemsProps = {
@@ -174,6 +178,20 @@ export function AddPanelMenuItems({
{t("task:plan")}
</DropdownMenuItem>
)}
{state.portForwarding && (
<DropdownMenuCheckboxItem
checked={state.portForwarding.enabled}
disabled={!state.portForwarding.canToggle || state.portForwarding.isUpdating}
onCheckedChange={() =>
void state.portForwarding?.togglePortForwarding({ openDialogOnEnable: true })
}
className={MENU_ITEM_CLASS}
data-testid="port-forwarding-menu-item"
>
<IconNetwork className={MENU_ICON_CLASS} />
{t("task:portForwarding")}
</DropdownMenuCheckboxItem>
)}
<PluginTaskPanelMenuItems groupId={groupId} />
{!state.isPassthrough && (
<DropdownMenuItem onClick={() => addTodosPanel({ groupId })} className={MENU_ITEM_CLASS}>
@@ -37,6 +37,7 @@ import { NewTaskDropdown } from "./new-task-dropdown";
import { useActiveSessionDevScript } from "./repository-scripts-menu";
import { GroupSplitCloseActionsView, useDockviewGroupWidth } from "./dockview-group-actions";
import { useTranslation } from "react-i18next";
import { useOptionalPortForwardingVisibility } from "./port-forwarding-visibility-provider";
const HEADER_ACTION_BUTTON_CLASS =
"h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring";
@@ -74,6 +75,7 @@ function useLeftHeaderState(
});
const { prs } = useTaskPR(taskId);
const mrs = useTaskMRs(taskId);
const portForwarding = useOptionalPortForwardingVisibility();
const hasChanges = Boolean(
containerApi.getPanel("changes") ?? containerApi.getPanel("diff-files"),
);
@@ -88,6 +90,7 @@ function useLeftHeaderState(
mrs,
hasChanges,
hasFiles,
portForwarding,
};
}
@@ -16,6 +16,7 @@ import { AddPanelMenuItems } from "./dockview-add-panel-items";
import { NewSessionDialog } from "./new-session-dialog";
import { useActiveSessionDevScript } from "./repository-scripts-menu";
import { useTranslation } from "react-i18next";
import { useOptionalPortForwardingVisibility } from "./port-forwarding-visibility-provider";
/**
* Watermark rendered by Dockview when a group becomes empty (e.g. after
@@ -96,6 +97,7 @@ function useWatermarkMenuState(
});
const { prs } = useTaskPR(taskID);
const mrs = useTaskMRs(taskID);
const portForwarding = useOptionalPortForwardingVisibility();
return useMemo(
() => ({
taskId: taskID,
@@ -104,8 +106,9 @@ function useWatermarkMenuState(
hasFiles: Boolean(containerApi.getPanel("files") ?? containerApi.getPanel("all-files")),
prs,
mrs,
portForwarding,
}),
[taskID, isPassthrough, containerApi, prs, mrs],
[taskID, isPassthrough, containerApi, prs, mrs, portForwarding],
);
}
@@ -17,6 +17,7 @@ import {
} from "./session-mobile-top-bar-git-controls";
import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { linkToTaskOverview } from "@/lib/links";
import { useTranslation } from "react-i18next";
@@ -241,6 +242,7 @@ function MobileTopBarActions({
return (
<div className="flex items-center gap-1" data-testid="mobile-topbar-actions">
<MRTopbarButton compact mobile />
{!isArchived && <PortForwardButton sessionId={sessionId} />}
{!isArchived && (
<TaskTopBarPluginActions
sessionId={sessionId ?? null}
@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { IconMessageCircle, IconPlus } from "@tabler/icons-react";
import { IconCheck, IconMessageCircle, IconNetwork, IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Button } from "@kandev/ui/button";
@@ -30,6 +30,7 @@ import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hoo
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { useMobileTaskRename } from "./use-mobile-task-rename";
import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit";
import { usePortForwardingVisibility } from "../port-forwarding-visibility-provider";
type SessionTaskSwitcherSheetProps = {
open: boolean;
@@ -331,6 +332,12 @@ function TaskSwitcherSurfaceContent({
onQuickChat={onQuickChat}
onNewTask={onNewTask}
/>
{data.activeTaskId && (
<PortForwardingTaskAction
onClose={() => onOpenChange(false)}
activeTaskId={data.activeTaskId}
/>
)}
<div className="shrink-0">
<SidebarFilterBar />
</div>
@@ -390,6 +397,37 @@ function TaskSwitcherSurfaceContent({
);
}
function PortForwardingTaskAction({
activeTaskId,
onClose,
}: {
activeTaskId: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const { enabled, canToggle, isUpdating, togglePortForwarding } = usePortForwardingVisibility();
return (
<div className="shrink-0 border-b border-border px-2 py-1">
<Button
variant="ghost"
className="min-h-11 w-full justify-start gap-3 px-3 text-sm"
data-testid="mobile-port-forwarding-toggle"
aria-pressed={enabled}
disabled={!canToggle || isUpdating || !activeTaskId}
onClick={() => {
onClose();
void togglePortForwarding({ openDialogOnEnable: true });
}}
>
<IconNetwork className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 text-left">{t("task:portForwarding")}</span>
{enabled && <IconCheck className="h-4 w-4 shrink-0" />}
</Button>
</div>
);
}
function TaskSwitcherDialogs({
dialogOpen,
onDialogOpenChange,
@@ -0,0 +1,83 @@
"use client";
import { useCallback, useState } from "react";
import { IconBrowser, IconCheck, IconCopy, IconExternalLink } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useTranslation } from "react-i18next";
export function PortUrlActions({
url,
onOpenBrowserPanel,
browserActionTestId,
}: {
url: string;
onOpenBrowserPanel?: (url: string) => void;
browserActionTestId?: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
if (await copyToClipboard(url)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [url]);
return (
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
aria-label={t("task:copyUrl")}
className="cursor-pointer min-h-11 min-w-11 p-0 sm:h-7 sm:w-7 sm:min-h-0 sm:min-w-0"
onClick={handleCopy}
>
{copied ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconCopy className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:copyUrl")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
aria-label={t("task:openInNewTab")}
className="cursor-pointer min-h-11 min-w-11 p-0 sm:h-7 sm:w-7 sm:min-h-0 sm:min-w-0"
asChild
>
<a href={url} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:openInNewTab")}</TooltipContent>
</Tooltip>
{onOpenBrowserPanel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
aria-label={t("task:openInBrowserPanel")}
data-testid={browserActionTestId}
className="cursor-pointer min-h-11 min-w-11 p-0 sm:h-7 sm:w-7 sm:min-h-0 sm:min-w-0"
onClick={() => onOpenBrowserPanel(url)}
>
<IconBrowser className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:openInBrowserPanel")}</TooltipContent>
</Tooltip>
)}
</div>
);
}
@@ -0,0 +1,155 @@
import { type ButtonHTMLAttributes, type PropsWithChildren } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PortForwardButton } from "./port-forward-dialog";
type Tunnel = { port: number; tunnel_port: number };
const { listPortsMock, listTunnelsMock, visibilityMock, dockviewMock } = vi.hoisted(() => ({
listPortsMock: vi.fn(),
listTunnelsMock: vi.fn(),
visibilityMock: {
enabled: true,
canToggle: true,
dialogOpen: false,
setDialogOpen: vi.fn(),
},
dockviewMock: {
api: {} as object | null,
openBrowserPanel: vi.fn(),
},
}));
vi.mock("@/lib/api/domains/port-api", () => ({
listPorts: listPortsMock,
listTunnels: listTunnelsMock,
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: typeof dockviewMock) => unknown) => selector(dockviewMock),
}));
vi.mock("./port-forwarding-visibility-provider", () => ({
usePortForwardingVisibility: () => visibilityMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@kandev/ui/button", () => ({
Button: ({
variant,
children,
...props
}: PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }>) => (
<button data-variant={variant} {...props}>
{children}
</button>
),
}));
vi.mock("@kandev/ui/dialog", () => ({
Dialog: ({ children }: PropsWithChildren) => <>{children}</>,
DialogContent: ({ children, ...props }: PropsWithChildren<Record<string, unknown>>) => (
<div {...props}>{children}</div>
),
DialogHeader: () => null,
DialogTitle: () => null,
DialogTrigger: ({ children }: PropsWithChildren) => <>{children}</>,
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: PropsWithChildren) => <>{children}</>,
TooltipContent: () => null,
TooltipTrigger: ({ children }: PropsWithChildren) => <>{children}</>,
}));
vi.mock("./use-tunnel-actions", () => ({
useTunnelActions: () => ({
pendingTunnels: new Set<number>(),
handleTunnelStart: vi.fn(),
handleTunnelStop: vi.fn(),
}),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe("PortForwardButton", () => {
beforeEach(() => {
listPortsMock.mockReset();
listTunnelsMock.mockReset();
listPortsMock.mockResolvedValue([]);
listTunnelsMock.mockResolvedValue([]);
visibilityMock.enabled = true;
visibilityMock.canToggle = true;
visibilityMock.dialogOpen = false;
visibilityMock.setDialogOpen.mockReset();
dockviewMock.api = {};
dockviewMock.openBrowserPanel.mockReset();
});
afterEach(() => cleanup());
it("ignores tunnel results from a previous session", async () => {
const firstSession = deferred<Tunnel[]>();
const secondSession = deferred<Tunnel[]>();
listTunnelsMock.mockImplementation((sessionId: string) =>
sessionId === "session-1" ? firstSession.promise : secondSession.promise,
);
const { rerender } = render(<PortForwardButton sessionId="session-1" />);
rerender(<PortForwardButton sessionId="session-2" />);
await act(async () => {
secondSession.resolve([]);
await secondSession.promise;
});
expect(screen.getByTestId("port-forward-button").getAttribute("data-variant")).toBe("outline");
await act(async () => {
firstSession.resolve([{ port: 3000, tunnel_port: 4000 }]);
await firstSession.promise;
});
expect(screen.getByTestId("port-forward-button").getAttribute("data-variant")).toBe("outline");
});
it("opens a proxy URL in the Browser panel and closes the dialog", () => {
render(<PortForwardButton sessionId="session-1" />);
fireEvent.change(screen.getByTestId("port-forward-port-input"), {
target: { value: "3000" },
});
fireEvent.click(screen.getByTestId("port-forward-add-button"));
fireEvent.click(screen.getByTestId("port-forward-open-browser-3000"));
expect(dockviewMock.openBrowserPanel).toHaveBeenCalledWith(
expect.stringContaining("/port-proxy/session-1/3000/"),
);
expect(visibilityMock.setDialogOpen).toHaveBeenCalledWith(false);
});
it("reloads tunnels when the control becomes visible", async () => {
visibilityMock.enabled = false;
listTunnelsMock.mockResolvedValue([{ port: 3000, tunnel_port: 4000 }]);
const { rerender } = render(<PortForwardButton sessionId="session-1" />);
expect(listTunnelsMock).not.toHaveBeenCalled();
visibilityMock.enabled = true;
rerender(<PortForwardButton sessionId="session-1" />);
await waitFor(() => {
expect(listTunnelsMock).toHaveBeenCalledWith("session-1");
expect(screen.getByTestId("port-forward-button").getAttribute("data-variant")).toBe(
"default",
);
});
});
});
@@ -3,9 +3,6 @@
import { useState, useCallback, useEffect, useRef } from "react";
import {
IconNetwork,
IconExternalLink,
IconCopy,
IconCheck,
IconRefresh,
IconPlus,
IconLoader2,
@@ -19,11 +16,13 @@ import { Badge } from "@kandev/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { listPorts, listTunnels, type ListeningPort } from "@/lib/api/domains/port-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { useTunnelActions } from "./use-tunnel-actions";
import { PortUrlActions } from "./port-forward-dialog-actions";
import { getBackendConfig } from "@/lib/config";
import { toast } from "@/lib/toast/sonner";
import { useTranslation } from "react-i18next";
import { usePortForwardingVisibility } from "./port-forwarding-visibility-provider";
import { useDockviewStore } from "@/lib/state/dockview-store";
function buildPortProxyUrl(sessionId: string, port: number): string {
const backendUrl = getBackendConfig().apiBaseUrl;
@@ -49,49 +48,6 @@ function InfoTip({ text }: { text: string }) {
);
}
function UrlActions({ url }: { url: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
if (await copyToClipboard(url)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [url]);
return (
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="cursor-pointer h-7 w-7 p-0"
onClick={handleCopy}
>
{copied ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconCopy className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:copyUrl")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="ghost" className="cursor-pointer h-7 w-7 p-0" asChild>
<a href={url} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:openInNewTab")}</TooltipContent>
</Tooltip>
</div>
);
}
function TunnelToggleButton({
isTunnelActive,
tunnelPending,
@@ -111,7 +67,8 @@ function TunnelToggleButton({
<Button
size="sm"
variant="ghost"
className={`cursor-pointer h-7 w-7 p-0 ${isTunnelActive ? "text-destructive hover:text-destructive" : ""}`}
aria-label={isTunnelActive ? t("task:stopTunnel") : t("task:startTunnel")}
className={`cursor-pointer min-h-11 min-w-11 p-0 sm:h-7 sm:w-7 sm:min-h-0 sm:min-w-0 ${isTunnelActive ? "text-destructive hover:text-destructive" : ""}`}
onClick={isTunnelActive ? onStop : onToggleForm}
disabled={tunnelPending}
>
@@ -134,11 +91,15 @@ function PortUrlRow({
tip,
url,
variant = "outline",
onOpenBrowserPanel,
browserActionTestId,
}: {
label: string;
tip: string;
url: string;
variant?: "outline" | "default";
onOpenBrowserPanel?: (url: string) => void;
browserActionTestId?: string;
}) {
return (
<div className="flex items-center gap-2 min-w-0">
@@ -148,13 +109,27 @@ function PortUrlRow({
<InfoTip text={tip} />
<span className="text-xs text-muted-foreground truncate min-w-0 flex-1">{url}</span>
<div className="shrink-0">
<UrlActions url={url} />
<PortUrlActions
url={url}
onOpenBrowserPanel={onOpenBrowserPanel}
browserActionTestId={browserActionTestId}
/>
</div>
</div>
);
}
function PortUrlRows({ proxyUrl, tunnelUrl }: { proxyUrl: string; tunnelUrl: string | null }) {
function PortUrlRows({
proxyUrl,
tunnelUrl,
onOpenBrowserPanel,
browserActionTestId,
}: {
proxyUrl: string;
tunnelUrl: string | null;
onOpenBrowserPanel?: (url: string) => void;
browserActionTestId: string;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1 overflow-hidden">
@@ -162,6 +137,8 @@ function PortUrlRows({ proxyUrl, tunnelUrl }: { proxyUrl: string; tunnelUrl: str
label={t("task:proxy")}
tip={t("task:pathBasedProxyWorksForApis")}
url={proxyUrl}
onOpenBrowserPanel={onOpenBrowserPanel}
browserActionTestId={browserActionTestId}
/>
{tunnelUrl && (
<PortUrlRow
@@ -185,6 +162,7 @@ type PortRowProps = {
tunnelPending?: boolean;
onTunnelStart: (port: number, requestedPort?: number) => void;
onTunnelStop: (port: number) => void;
onOpenBrowserPanel?: (url: string) => void;
};
function PortRow({
@@ -197,6 +175,7 @@ function PortRow({
tunnelPending,
onTunnelStart,
onTunnelStop,
onOpenBrowserPanel,
}: PortRowProps) {
const { t } = useTranslation();
const [showTunnelForm, setShowTunnelForm] = useState(false);
@@ -275,7 +254,12 @@ function PortRow({
</div>
)}
<PortUrlRows proxyUrl={proxyUrl} tunnelUrl={tunnelUrl} />
<PortUrlRows
proxyUrl={proxyUrl}
tunnelUrl={tunnelUrl}
onOpenBrowserPanel={onOpenBrowserPanel}
browserActionTestId={`port-forward-open-browser-${port}`}
/>
</div>
);
}
@@ -291,6 +275,7 @@ function PortListSection({
pendingTunnels,
onTunnelStart,
onTunnelStop,
onOpenBrowserPanel,
}: {
detectedPorts: ListeningPort[];
manualPorts: number[];
@@ -302,6 +287,7 @@ function PortListSection({
pendingTunnels: Set<number>;
onTunnelStart: (port: number, requestedPort?: number) => void;
onTunnelStop: (port: number) => void;
onOpenBrowserPanel?: (url: string) => void;
}) {
const { t } = useTranslation();
const detectedPortNumbers = new Set(detectedPorts.map((p) => p.port));
@@ -354,6 +340,7 @@ function PortListSection({
tunnelPending={pendingTunnels.has(p.port)}
onTunnelStart={onTunnelStart}
onTunnelStop={onTunnelStop}
onOpenBrowserPanel={onOpenBrowserPanel}
/>
))}
{uniqueManualPorts.map((port) => (
@@ -366,6 +353,7 @@ function PortListSection({
tunnelPending={pendingTunnels.has(port)}
onTunnelStart={onTunnelStart}
onTunnelStop={onTunnelStop}
onOpenBrowserPanel={onOpenBrowserPanel}
/>
))}
</div>
@@ -424,10 +412,12 @@ function PortForwardDialogContent({
sessionId,
activeTunnels,
setActiveTunnels,
onOpenBrowserPanel,
}: {
sessionId: string;
activeTunnels: Map<number, number>;
setActiveTunnels: (updater: (prev: Map<number, number>) => Map<number, number>) => void;
onOpenBrowserPanel?: (url: string) => void;
}) {
const { t } = useTranslation();
const [detectedPorts, setDetectedPorts] = useState<ListeningPort[]>([]);
@@ -480,7 +470,7 @@ function PortForwardDialogContent({
return (
<DialogContent
data-testid="port-forward-dialog"
className="sm:max-w-2xl overflow-hidden"
className="max-h-[calc(100dvh-2rem)] overflow-hidden sm:max-w-2xl"
onOpenAutoFocus={() => !loaded && refresh()}
>
<DialogHeader>
@@ -489,7 +479,7 @@ function PortForwardDialogContent({
{t("task:portForwarding")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 min-w-0 max-h-[60vh] overflow-y-auto">
<div className="min-w-0 max-h-[calc(100dvh-8rem)] space-y-4 overflow-y-auto overscroll-contain sm:max-h-[60vh]">
<PortListSection
detectedPorts={detectedPorts}
manualPorts={manualPorts}
@@ -501,6 +491,7 @@ function PortForwardDialogContent({
pendingTunnels={pendingTunnels}
onTunnelStart={handleTunnelStart}
onTunnelStop={handleTunnelStop}
onOpenBrowserPanel={onOpenBrowserPanel}
/>
<ManualPortInput onAdd={handleAddManual} />
</div>
@@ -508,18 +499,22 @@ function PortForwardDialogContent({
);
}
export function PortForwardButton({
isRemoteExecutor,
sessionId,
isAgentctlReady,
}: {
isRemoteExecutor?: boolean;
sessionId?: string | null;
isAgentctlReady?: boolean;
}) {
export function PortForwardButton({ sessionId }: { sessionId?: string | null }) {
const { t } = useTranslation();
const { enabled, canToggle, dialogOpen, setDialogOpen } = usePortForwardingVisibility();
const openBrowserPanel = useDockviewStore((state) =>
state.api ? state.openBrowserPanel : undefined,
);
const [activeTunnels, setActiveTunnelsRaw] = useState<Map<number, number>>(new Map());
const hasActiveTunnels = activeTunnels.size > 0;
const handleOpenBrowserPanel = useCallback(
(url: string) => {
if (!openBrowserPanel) return;
openBrowserPanel(url);
setDialogOpen(false);
},
[openBrowserPanel, setDialogOpen],
);
const setActiveTunnels = useCallback(
(updater: (prev: Map<number, number>) => Map<number, number>) => {
@@ -529,16 +524,24 @@ export function PortForwardButton({
);
useEffect(() => {
if (!sessionId || !isAgentctlReady) return;
if (!enabled || !sessionId || !canToggle) {
setActiveTunnelsRaw(new Map());
return;
}
let cancelled = false;
listTunnels(sessionId).then((tunnels) => {
if (cancelled) return;
setActiveTunnelsRaw(new Map(tunnels.map((t) => [t.port, t.tunnel_port])));
});
}, [sessionId, isAgentctlReady]);
return () => {
cancelled = true;
};
}, [canToggle, enabled, sessionId]);
if (!isRemoteExecutor || !sessionId || !isAgentctlReady) return null;
if (!enabled || !canToggle || !sessionId) return null;
return (
<Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
@@ -562,6 +565,7 @@ export function PortForwardButton({
sessionId={sessionId}
activeTunnels={activeTunnels}
setActiveTunnels={setActiveTunnels}
onOpenBrowserPanel={openBrowserPanel ? handleOpenBrowserPanel : undefined}
/>
</Dialog>
);
@@ -0,0 +1,199 @@
import { act, renderHook } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
PortForwardingVisibilityProvider,
useOptionalPortForwardingVisibility,
usePortForwardingVisibility,
} from "./port-forwarding-visibility-provider";
const { updateTaskPortForwardingMock, toastMock } = vi.hoisted(() => ({
updateTaskPortForwardingMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/lib/api/domains/kanban-api", () => ({
updateTaskPortForwarding: updateTaskPortForwardingMock,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function wrapper({ children }: PropsWithChildren) {
return (
<PortForwardingVisibilityProvider
taskId="task-1"
metadata={{ port_forwarding_enabled: true }}
sessionId="session-1"
isAgentctlReady
>
{children}
</PortForwardingVisibilityProvider>
);
}
beforeEach(() => {
updateTaskPortForwardingMock.mockReset();
toastMock.mockReset();
});
describe("PortForwardingVisibilityProvider", () => {
it("rolls back a failed preference write and reports translated feedback", async () => {
updateTaskPortForwardingMock.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => usePortForwardingVisibility(), { wrapper });
await act(async () => {
await result.current.togglePortForwarding();
});
expect(updateTaskPortForwardingMock).toHaveBeenCalledWith("task-1", false);
expect(result.current.enabled).toBe(true);
expect(toastMock).toHaveBeenCalledWith({
variant: "error",
description: "task:portForwardingPreferenceUpdateFailed",
});
});
it("opens the existing dialog after a successful enable", async () => {
updateTaskPortForwardingMock.mockResolvedValueOnce({
metadata: { port_forwarding_enabled: true },
});
const { result } = renderHook(() => usePortForwardingVisibility(), {
wrapper: ({ children }) => (
<PortForwardingVisibilityProvider
taskId="task-1"
metadata={{ port_forwarding_enabled: false }}
sessionId="session-1"
isAgentctlReady
>
{children}
</PortForwardingVisibilityProvider>
),
});
await act(async () => {
await result.current.togglePortForwarding({ openDialogOnEnable: true });
});
expect(updateTaskPortForwardingMock).toHaveBeenCalledWith("task-1", true);
expect(result.current.enabled).toBe(true);
expect(result.current.dialogOpen).toBe(true);
});
it("discards an in-flight toggle result when the task switches", async () => {
let resolveToggle!: () => void;
updateTaskPortForwardingMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveToggle = resolve;
}),
);
let providerProps = {
taskId: "task-1",
metadata: { port_forwarding_enabled: false } as Record<string, unknown>,
};
const dynamicWrapper = ({ children }: PropsWithChildren) => (
<PortForwardingVisibilityProvider
taskId={providerProps.taskId}
metadata={providerProps.metadata}
sessionId="session-1"
isAgentctlReady
>
{children}
</PortForwardingVisibilityProvider>
);
const { result, rerender } = renderHook(() => usePortForwardingVisibility(), {
wrapper: dynamicWrapper,
});
act(() => {
void result.current.togglePortForwarding({ openDialogOnEnable: true });
});
expect(updateTaskPortForwardingMock).toHaveBeenCalledWith("task-1", true);
providerProps = { taskId: "task-2", metadata: {} };
rerender();
expect(result.current.enabled).toBe(false);
expect(result.current.isUpdating).toBe(false);
await act(async () => {
resolveToggle();
});
expect(result.current.enabled).toBe(false);
expect(result.current.dialogOpen).toBe(false);
expect(result.current.isUpdating).toBe(false);
});
});
describe("PortForwardingVisibilityProvider reconciliation", () => {
it("keeps a successful toggle while task metadata is still stale", async () => {
let resolveToggle!: () => void;
updateTaskPortForwardingMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveToggle = resolve;
}),
);
const { result } = renderHook(() => usePortForwardingVisibility(), {
wrapper: ({ children }) => (
<PortForwardingVisibilityProvider
taskId="task-1"
metadata={{ port_forwarding_enabled: false }}
sessionId="session-1"
isAgentctlReady
>
{children}
</PortForwardingVisibilityProvider>
),
});
act(() => {
void result.current.togglePortForwarding();
});
expect(result.current.isUpdating).toBe(true);
await act(async () => {
resolveToggle();
});
expect(result.current.enabled).toBe(true);
expect(result.current.isUpdating).toBe(false);
});
});
describe("PortForwardingVisibility context boundaries", () => {
it("returns no visibility state outside a task provider", () => {
const { result } = renderHook(() => useOptionalPortForwardingVisibility());
expect(result.current).toBeUndefined();
});
it("closes the dialog when agentctl readiness is lost", () => {
let isAgentctlReady = true;
const dynamicWrapper = ({ children }: PropsWithChildren) => (
<PortForwardingVisibilityProvider
taskId="task-1"
metadata={{ port_forwarding_enabled: true }}
sessionId="session-1"
isAgentctlReady={isAgentctlReady}
>
{children}
</PortForwardingVisibilityProvider>
);
const { result, rerender } = renderHook(() => usePortForwardingVisibility(), {
wrapper: dynamicWrapper,
});
act(() => result.current.setDialogOpen(true));
expect(result.current.dialogOpen).toBe(true);
isAgentctlReady = false;
rerender();
expect(result.current.dialogOpen).toBe(false);
});
});
@@ -0,0 +1,158 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import { useToast } from "@/components/toast-provider";
import { updateTaskPortForwarding } from "@/lib/api/domains/kanban-api";
import type { Task } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { canUsePortForwarding, isPortForwardingEnabled } from "./port-forwarding-visibility";
export type PortForwardingToggleOptions = {
openDialogOnEnable?: boolean;
};
export type PortForwardingVisibility = {
enabled: boolean;
canToggle: boolean;
isUpdating: boolean;
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
togglePortForwarding: (options?: PortForwardingToggleOptions) => Promise<void>;
};
type PortForwardingVisibilityProviderProps = {
taskId?: string | null;
metadata?: Task["metadata"];
sessionId?: string | null;
isAgentctlReady: boolean;
isArchived?: boolean;
children: ReactNode;
};
const PortForwardingVisibilityContext = createContext<PortForwardingVisibility | null>(null);
export function useOptionalPortForwardingVisibility(): PortForwardingVisibility | undefined {
return useContext(PortForwardingVisibilityContext) ?? undefined;
}
export function usePortForwardingVisibility(): PortForwardingVisibility {
const context = useOptionalPortForwardingVisibility();
if (!context) {
throw new Error(
"usePortForwardingVisibility must be used within PortForwardingVisibilityProvider",
);
}
return context;
}
export function PortForwardingVisibilityProvider({
taskId,
metadata,
sessionId,
isAgentctlReady,
isArchived = false,
children,
}: PortForwardingVisibilityProviderProps) {
const { t } = useTranslation();
const { toast } = useToast();
const persistedEnabled = isPortForwardingEnabled(metadata);
const taskKey = taskId ?? "";
const [enabled, setEnabled] = useState(persistedEnabled);
const [isUpdating, setIsUpdating] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const lastTaskKeyRef = useRef(taskKey);
const lastServerValueRef = useRef(persistedEnabled);
const pendingServerValueRef = useRef<boolean | null>(null);
const requestRef = useRef(0);
const canToggle = !isArchived && canUsePortForwarding({ sessionId, isAgentctlReady });
useEffect(() => {
if (lastTaskKeyRef.current !== taskKey) {
lastTaskKeyRef.current = taskKey;
lastServerValueRef.current = persistedEnabled;
pendingServerValueRef.current = null;
requestRef.current += 1;
setEnabled(persistedEnabled);
setIsUpdating(false);
setDialogOpen(false);
return;
}
if (pendingServerValueRef.current !== null) {
if (pendingServerValueRef.current === persistedEnabled) {
pendingServerValueRef.current = null;
lastServerValueRef.current = persistedEnabled;
} else {
return;
}
}
if (!isUpdating && lastServerValueRef.current !== persistedEnabled) {
lastServerValueRef.current = persistedEnabled;
setEnabled(persistedEnabled);
}
}, [isUpdating, persistedEnabled, taskKey]);
useEffect(() => {
if (!canToggle) setDialogOpen(false);
}, [canToggle]);
const togglePortForwarding = useCallback(
async (options?: PortForwardingToggleOptions) => {
if (!taskId || !canToggle || isUpdating) return;
const previousEnabled = enabled;
const nextEnabled = !enabled;
const requestId = requestRef.current + 1;
requestRef.current = requestId;
setEnabled(nextEnabled);
setIsUpdating(true);
if (!nextEnabled) setDialogOpen(false);
try {
await updateTaskPortForwarding(taskId, nextEnabled);
if (requestRef.current !== requestId) return;
pendingServerValueRef.current = nextEnabled;
lastServerValueRef.current = nextEnabled;
setEnabled(nextEnabled);
if (nextEnabled && options?.openDialogOnEnable) setDialogOpen(true);
} catch {
if (requestRef.current !== requestId) return;
setEnabled(previousEnabled);
toast({
variant: "error",
description: t("task:portForwardingPreferenceUpdateFailed"),
});
} finally {
if (requestRef.current === requestId) setIsUpdating(false);
}
},
[canToggle, enabled, isUpdating, t, taskId, toast],
);
const value = useMemo<PortForwardingVisibility>(
() => ({
enabled,
canToggle,
isUpdating,
dialogOpen,
setDialogOpen,
togglePortForwarding,
}),
[canToggle, dialogOpen, enabled, isUpdating, togglePortForwarding],
);
return (
<PortForwardingVisibilityContext.Provider value={value}>
{children}
</PortForwardingVisibilityContext.Provider>
);
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { canUsePortForwarding, isPortForwardingEnabled } from "./port-forwarding-visibility";
describe("port-forwarding visibility", () => {
it.each([
[{ port_forwarding_enabled: true }, true],
[{ port_forwarding_enabled: false }, false],
[{ port_forwarding_enabled: "true" }, false],
[null, false],
[undefined, false],
])("reads only an explicit true preference", (metadata, expected) => {
expect(isPortForwardingEnabled(metadata)).toBe(expected);
});
it.each([
["session-1", true, true],
["session-1", false, false],
[null, true, false],
[undefined, true, false],
])("requires a session and ready agentctl", (sessionId, ready, expected) => {
expect(canUsePortForwarding({ sessionId, isAgentctlReady: ready })).toBe(expected);
});
});
@@ -0,0 +1,16 @@
import type { Task } from "@/lib/types/http";
export const PORT_FORWARDING_METADATA_KEY = "port_forwarding_enabled";
export function isPortForwardingEnabled(
metadata: Task["metadata"] | Record<string, unknown> | null | undefined,
): boolean {
return metadata?.[PORT_FORWARDING_METADATA_KEY] === true;
}
export function canUsePortForwarding(params: {
sessionId?: string | null;
isAgentctlReady: boolean;
}): boolean {
return Boolean(params.sessionId && params.isAgentctlReady);
}
@@ -228,6 +228,27 @@ describe("syncActiveTaskSession", () => {
});
describe("resolveEffectiveTask archived state", () => {
it("builds a kanban-only task with its metadata", () => {
const metadata = { port_forwarding_enabled: true };
const resolved = resolveEffectiveTask(null, null, makeKanbanTask({ metadata }), "task-1");
expect(resolved?.metadata).toEqual(metadata);
});
it("uses live kanban metadata while preserving base metadata when omitted", () => {
const base = makeArchivedTaskDetails({ metadata: { port_forwarding_enabled: false } });
const enabled = resolveEffectiveTask(
base,
null,
makeKanbanTask({ metadata: { port_forwarding_enabled: true } }),
"task-1",
);
expect(enabled?.metadata).toEqual({ port_forwarding_enabled: true });
const omitted = resolveEffectiveTask(base, null, makeKanbanTask(), "task-1");
expect(omitted?.metadata).toEqual({ port_forwarding_enabled: false });
});
it("keeps fetched archived state when a stale matching kanban card remains", () => {
const taskDetails = makeArchivedTaskDetails();
const kanbanTask = makeKanbanTask({ updatedAt: "2026-07-18T00:00:00Z" });
@@ -140,6 +140,7 @@ export function mergeBaseWithKanban(
position: kanbanTask.position ?? baseTask.position,
state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state,
repositories: baseTask.repositories,
metadata: kanbanTask.metadata !== undefined ? kanbanTask.metadata : baseTask.metadata,
archived_at: hasNewerKanbanState ? null : baseTask.archived_at,
};
}
@@ -158,6 +159,7 @@ export function buildTaskFromKanban(kanbanTask: KanbanState["tasks"][number]): T
repositories: [],
created_at: "",
updated_at: kanbanTask.updatedAt ?? "",
metadata: kanbanTask.metadata,
};
}
+36 -31
View File
@@ -16,6 +16,7 @@ import { SessionCommands } from "@/components/session-commands";
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
import { useEmbeddedVscodeSupport } from "@/components/task/task-page-editor-capability";
import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs";
import { PortForwardingVisibilityProvider } from "@/components/task/port-forwarding-visibility-provider";
import {
buildDebugEntries,
buildArchivedValue,
@@ -104,7 +105,6 @@ function buildTaskTopBarProps(params: {
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
agentctlReady: boolean;
embeddedVscodeSupported: boolean;
officeTaskHref?: string | null;
onTaskUnarchived: (taskId: string) => void;
@@ -128,8 +128,6 @@ function buildTaskTopBarProps(params: {
issueUrl: taskProps.issueUrl,
issueNumber: taskProps.issueNumber,
isArchived: taskProps.isArchived,
isRemoteExecutor: params.remote.isRemoteExecutor,
isAgentctlReady: params.agentctlReady,
embeddedVscodeSupported: params.embeddedVscodeSupported,
remoteExecutorType: params.remote.remoteExecutorType,
officeTaskHref: params.officeTaskHref,
@@ -260,7 +258,6 @@ function useTaskPageDerivedProps({
effectiveSessionId,
remote,
sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
agentctlReady: agentctlStatus.isReady,
embeddedVscodeSupported: embeddedVscode,
officeTaskHref,
onTaskUnarchived,
@@ -287,36 +284,44 @@ export function TaskPageInner(props: TaskPageInnerProps) {
return (
<TooltipProvider>
<VcsDialogsProvider
<PortForwardingVisibilityProvider
taskId={taskProps.taskId}
metadata={task?.metadata}
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
isAgentctlReady={props.agentctlStatus.isReady}
isArchived={taskProps.isArchived}
>
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-background">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
isTaskArchived={archivedValue.isArchived}
/>
<TaskPRShortcut taskId={taskProps.taskId} />
<TaskDebugOverlay entries={debugEntries} />
{!isMobile && <TaskTopBar {...topBarProps} />}
{ensureSession.status === "error" && (
<EnsureSessionErrorBanner
error={ensureSession.error}
onRetry={ensureSession.retry}
workspaceId={task?.workspace_id ?? null}
<VcsDialogsProvider
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-background">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
isAgentRunning={merged.isAgentWorking}
hasWorktree={Boolean(merged.worktreeBranch)}
isPassthrough={sessionPanel.isSessionPassthrough}
isTaskArchived={archivedValue.isArchived}
/>
)}
<TaskArchivedProvider value={archivedValue}>
<TaskLayout {...layoutProps} />
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
<TaskPRShortcut taskId={taskProps.taskId} />
<TaskDebugOverlay entries={debugEntries} />
{!isMobile && <TaskTopBar {...topBarProps} />}
{ensureSession.status === "error" && (
<EnsureSessionErrorBanner
error={ensureSession.error}
onRetry={ensureSession.retry}
workspaceId={task?.workspace_id ?? null}
/>
)}
<TaskArchivedProvider value={archivedValue}>
<TaskLayout {...layoutProps} />
</TaskArchivedProvider>
</div>
</VcsDialogsProvider>
</PortForwardingVisibilityProvider>
</TooltipProvider>
);
}
+1 -21
View File
@@ -41,8 +41,6 @@ type TaskTopBarProps = {
issueUrl?: string;
issueNumber?: number;
isArchived?: boolean;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
embeddedVscodeSupported?: boolean;
remoteExecutorType?: string | null;
officeTaskHref?: string | null;
@@ -68,8 +66,6 @@ const TaskTopBar = memo(function TaskTopBar({
workflowId,
workspaceId,
isArchived,
isRemoteExecutor,
isAgentctlReady,
embeddedVscodeSupported,
issueUrl,
issueNumber,
@@ -107,8 +103,6 @@ const TaskTopBar = memo(function TaskTopBar({
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
embeddedVscodeSupported={embeddedVscodeSupported}
taskTitle={taskTitle}
issueUrl={issueUrl}
@@ -218,8 +212,6 @@ function AttentionStatusGroup({
activeSessionId,
isArchived,
workspaceId,
isRemoteExecutor,
isAgentctlReady,
taskTitle,
issueUrl,
issueNumber,
@@ -227,8 +219,6 @@ function AttentionStatusGroup({
activeSessionId?: string | null;
isArchived?: boolean;
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
taskTitle?: string;
issueUrl?: string;
issueNumber?: number;
@@ -249,11 +239,7 @@ function AttentionStatusGroup({
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<PortForwardButton
isRemoteExecutor={isRemoteExecutor}
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
<PortForwardButton sessionId={activeSessionId} />
{/* PR (GitHub) and MR (GitLab) buttons each render nothing when no
rows match, so showing both covers GitHub-only, GitLab-only, and
multi-repo tasks without needing an explicit provider switch. */}
@@ -349,8 +335,6 @@ function TopBarRight({
onToggleDebugOverlay,
isArchived,
workspaceId,
isRemoteExecutor,
isAgentctlReady,
embeddedVscodeSupported,
taskTitle,
issueUrl,
@@ -364,8 +348,6 @@ function TopBarRight({
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
embeddedVscodeSupported?: boolean;
taskTitle?: string;
issueUrl?: string;
@@ -409,8 +391,6 @@ function TopBarRight({
activeSessionId={activeSessionId}
isArchived={isArchived}
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
issueUrl={issueUrl}
issueNumber={issueNumber}
+54
View File
@@ -39,6 +39,15 @@ export class SessionPage {
get portForwardButton() {
return this.page.getByTestId("port-forward-button");
}
get portForwardingMenuItem() {
return this.page.getByTestId("port-forwarding-menu-item");
}
get mobileSessionMenu() {
return this.page.getByTestId("mobile-session-menu");
}
get mobilePortForwardingToggle() {
return this.page.getByTestId("mobile-port-forwarding-toggle");
}
get portForwardDialog() {
return this.page.getByTestId("port-forward-dialog");
}
@@ -54,6 +63,45 @@ export class SessionPage {
portForwardRow(port: number) {
return this.page.getByTestId(`port-forward-row-${port}`);
}
portForwardTunnelToggle(port: number) {
return this.portForwardRow(port).getByRole("button").first();
}
portForwardTunnelStart(port: number) {
return this.portForwardRow(port).getByRole("button", { name: "Start", exact: true });
}
portForwardOpenBrowser(port: number) {
return this.portForwardRow(port).getByTestId(`port-forward-open-browser-${port}`);
}
get browserPanel() {
return this.page.locator('[data-testid="browser-panel"]:visible').first();
}
get browserAddressInput() {
return this.browserPanel.locator("input").first();
}
async togglePortForwardingPreference(): Promise<void> {
await this.addPanelButton().click();
await expect(this.portForwardingMenuItem).toBeVisible();
const enabling = (await this.portForwardingMenuItem.getAttribute("aria-checked")) !== "true";
await this.portForwardingMenuItem.click({ force: true });
if (enabling) {
await this.portForwardDialog
.waitFor({ state: "visible", timeout: 5_000 })
.catch(() => undefined);
}
if (await this.portForwardDialog.isVisible()) {
await this.portForwardDialog.getByRole("button", { name: "Close" }).click();
}
await this.page.keyboard.press("Escape");
await expect(this.portForwardingMenuItem).toBeHidden();
await expect(this.portForwardDialog).toBeHidden();
}
async enablePortForwarding(): Promise<void> {
if (await this.portForwardButton.isVisible()) return;
await this.togglePortForwardingPreference();
await expect(this.portForwardButton).toBeVisible();
}
// Chat status bar locators
appStatusBar() {
@@ -1323,6 +1371,12 @@ export class SessionPage {
return this.page.getByTestId("dockview-add-panel-btn").first();
}
/** Open a blank built-in Browser panel from the dockview + menu. */
async addBrowserPanel(): Promise<void> {
await this.addPanelButton().click();
await this.page.getByRole("menuitem", { name: "Browser", exact: true }).click();
}
/** "New Session" menu item in the dockview + dropdown. */
newSessionMenuButton(): Locator {
return this.page.getByTestId("new-session-button");
@@ -0,0 +1,47 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import { seedIdleSession } from "../../helpers/session";
import {
assertNoDocumentHorizontalOverflow,
assertLocatorWithinViewportX,
} from "../../helpers/layout-assertions";
test.describe("Port forwarding on mobile", () => {
test("enables from the active-task drawer action and opens a safe dialog", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedIdleSession(
testPage,
apiClient,
seedData,
"Mobile Port Forwarding Test",
);
await expect(session.portForwardButton).not.toBeVisible();
await session.mobileSessionMenu.tap();
await expect(session.mobilePortForwardingToggle).toBeVisible();
await expect(session.mobilePortForwardingToggle).toBeEnabled();
await session.mobilePortForwardingToggle.tap();
await expect(session.mobilePortForwardingToggle).toBeHidden();
await expect(session.portForwardButton).toBeVisible();
await expect(session.portForwardDialog).toBeVisible();
await assertLocatorWithinViewportX(session.portForwardDialog, "port forwarding dialog");
await assertNoDocumentHorizontalOverflow(testPage, "mobile port forwarding");
await session.portForwardInput.fill("3000");
await session.portForwardAddButton.tap();
const row = session.portForwardRow(3000);
await expect(row).toBeVisible();
await expect(row.locator("a[target='_blank']")).toHaveAttribute(
"href",
/\/port-proxy\/[^/]+\/3000\//,
);
await expect(session.portForwardOpenBrowser(3000)).toHaveCount(0);
await session.portForwardDialog.getByRole("button", { name: "Close" }).tap();
});
});
@@ -87,28 +87,107 @@ async function seedLocalSession(
}
test.describe("Port Forward Dialog", () => {
test("button is hidden for local executor session", async ({ testPage, apiClient, seedData }) => {
test("local executor can enable the port forwarding control", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedLocalSession(testPage, apiClient, seedData, "Local Port Test");
await expect(session.portForwardButton).not.toBeVisible();
await session.enablePortForwarding();
await expect(session.portForwardButton).toBeVisible();
});
test("button is visible for mock remote executor session", async ({
test("button is visible for mock remote executor after launcher enable", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Remote Port Test");
await expect(session.portForwardButton).toBeHidden();
await session.enablePortForwarding();
await expect(session.portForwardButton).toBeVisible();
});
test("disabling the preference hides the top-bar control", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Disable Port Forwarding Test",
);
await session.enablePortForwarding();
await expect(session.portForwardButton).toBeVisible();
await session.togglePortForwardingPreference();
await expect(session.portForwardButton).not.toBeVisible();
});
test("disabling visibility leaves an active tunnel available", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Active Tunnel Visibility Test",
);
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("3000");
await session.portForwardAddButton.click();
const row = session.portForwardRow(3000);
await expect(row).toBeVisible();
await session.portForwardTunnelToggle(3000).click();
await session.portForwardTunnelStart(3000).click();
await expect(row.locator("a[target='_blank']")).toHaveCount(2);
await session.portForwardDialog.getByRole("button", { name: "Close" }).click();
await session.togglePortForwardingPreference();
await expect(session.portForwardButton).not.toBeVisible();
await session.togglePortForwardingPreference();
await expect(session.portForwardButton).toBeVisible();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await expect(session.portForwardRow(3000).locator("a[target='_blank']")).toHaveCount(2);
await session.portForwardDialog.getByRole("button", { name: "Close" }).click();
});
test("preference survives a task page reload", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedLocalSession(
testPage,
apiClient,
seedData,
"Persist Port Forwarding Test",
);
await session.enablePortForwarding();
await testPage.reload();
await session.waitForLoad();
await expect(session.portForwardButton).toBeVisible();
});
test("dialog opens on button click", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Dialog Open Test");
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
});
test("auto-refresh loads port list on open", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Refresh Test");
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
// Dialog auto-refreshes on open; wait for the placeholder to disappear,
@@ -121,6 +200,7 @@ test.describe("Port Forward Dialog", () => {
test("add manual port shows row with Manual badge", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Manual Port Test");
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
@@ -132,8 +212,68 @@ test.describe("Port Forward Dialog", () => {
await expect(row.getByText("Manual")).toBeVisible();
});
test("opens the proxy URL in the existing Browser panel", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Open Proxy Browser Panel Test",
);
await session.enablePortForwarding();
await session.addBrowserPanel();
await expect(session.browserPanel).toBeVisible();
const browserPanelCount = await testPage.getByTestId("browser-panel").count();
expect(browserPanelCount).toBe(1);
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("3000");
await session.portForwardAddButton.click();
const row = session.portForwardRow(3000);
const proxyUrl = await row.locator("a[target='_blank']").getAttribute("href");
expect(proxyUrl).toBeTruthy();
await session.portForwardOpenBrowser(3000).click();
await expect(session.portForwardDialog).toBeHidden();
await expect(session.browserPanel).toBeVisible();
await expect(testPage.getByTestId("browser-panel")).toHaveCount(browserPanelCount);
await expect(session.browserAddressInput).toHaveValue(proxyUrl!);
await expect(session.browserPanel.locator("iframe")).toHaveAttribute("src", proxyUrl!);
});
test("opens the proxy URL in a new Browser panel", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Open Proxy In New Browser Panel Test",
);
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("3000");
await session.portForwardAddButton.click();
const row = session.portForwardRow(3000);
const proxyUrl = await row.locator("a[target='_blank']").getAttribute("href");
expect(proxyUrl).toBeTruthy();
await session.portForwardOpenBrowser(3000).click();
await expect(session.portForwardDialog).toBeHidden();
await expect(session.browserPanel).toBeVisible();
await expect(session.browserAddressInput).toHaveValue(proxyUrl!);
await expect(session.browserPanel.locator("iframe")).toHaveAttribute("src", proxyUrl!);
});
test("rejects invalid port number", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Invalid Port Test");
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
@@ -151,6 +291,7 @@ test.describe("Port Forward Dialog", () => {
seedData,
"Duplicate Port Test",
);
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
@@ -172,6 +313,7 @@ test.describe("Port Forward Dialog", () => {
seedData,
"Proxy URL Test",
);
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
@@ -188,6 +330,7 @@ test.describe("Port Forward Dialog", () => {
test("Enter key submits port", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Enter Key Test");
await session.enablePortForwarding();
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
+33 -1
View File
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { attachTaskWorkspaceSources, detachTask, listTasksByWorkspace } from "./kanban-api";
import {
attachTaskWorkspaceSources,
detachTask,
listTasksByWorkspace,
updateTaskPortForwarding,
} from "./kanban-api";
const fetchSpy = vi.fn<typeof fetch>();
const API_BASE_URL = "http://api.test";
@@ -30,6 +35,33 @@ describe("detachTask", () => {
});
});
describe("updateTaskPortForwarding", () => {
it("patches only the task-scoped preference endpoint", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "task-1", metadata: { port_forwarding_enabled: true } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
updateTaskPortForwarding("task-1", true, { baseUrl: API_BASE_URL }),
).resolves.toMatchObject({
id: "task-1",
metadata: { port_forwarding_enabled: true },
});
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/port-forwarding`);
expect(init).toMatchObject({
method: "PATCH",
body: JSON.stringify({ enabled: true }),
});
expect(new Headers(init?.headers).get("Content-Type")).toBe("application/json");
});
});
describe("attachTaskWorkspaceSources", () => {
it("posts the exact mixed-source payload and returns the persisted projection", async () => {
fetchSpy.mockResolvedValueOnce(
+15
View File
@@ -139,6 +139,21 @@ export async function updateTask(
});
}
export async function updateTaskPortForwarding(
taskId: string,
enabled: boolean,
options?: ApiRequestOptions,
) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}/port-forwarding`, {
...options,
init: {
method: "PATCH",
body: JSON.stringify({ enabled }),
...(options?.init ?? {}),
},
});
}
export async function detachTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}/detach`, {
...options,
+15 -1
View File
@@ -81,6 +81,16 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => {
expect(toKanbanTask(httpDTO())).toEqual(toKanbanTask(wsPayload()));
});
it("carries task metadata through both shapes", () => {
const metadata = { port_forwarding_enabled: true, unrelated: "preserve" };
const http = toKanbanTask(httpDTO({ metadata }));
const ws = toKanbanTask(wsPayload({ metadata }));
expect(http.metadata).toEqual(metadata);
expect(ws.metadata).toEqual(metadata);
expect(ws).toEqual(http);
});
it("PR review task: review_watch_id flags isPRReview from both shapes", () => {
const metadata = { review_watch_id: "watch-123" };
const out = toKanbanTask(httpDTO({ metadata }));
@@ -215,8 +225,12 @@ describe("toKanbanTask — state normalization", () => {
expect(first.isIssueWatch).toBe(false);
expect(first.issueUrl).toBeUndefined();
expect(first.issueNumber).toBeUndefined();
expect(mapped[1].metadata).toBeUndefined();
expect(mapped[2].metadata).toEqual({});
const { metadata: _firstMetadata, ...firstWithoutMetadata } = first;
for (const out of mapped.slice(1)) {
expect(out).toEqual(first);
const { metadata: _metadata, ...outWithoutMetadata } = out;
expect(outWithoutMetadata).toEqual(firstWithoutMetadata);
}
});
+1
View File
@@ -165,6 +165,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask {
queuedForStepId: source.queued_for_step_id,
queuedAt: source.queued_at,
statusSummary: source.status_summary,
metadata: source.metadata,
isArchived: source.archived_at != null,
isPRReview: isPRReviewFromMetadata(source.metadata),
isIssueWatch: isIssueWatchFromMetadata(source.metadata),
+12
View File
@@ -59,6 +59,18 @@ describe("snapshotToState", () => {
expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined();
});
it("hydrates task metadata into the initial kanban state", () => {
const snapshot = snapshotWithPendingAction(undefined);
snapshot.tasks[0].metadata = {
port_forwarding_enabled: true,
unrelated: "preserve",
};
const state = snapshotToState(snapshot);
expect(state.kanban?.tasks[0]?.metadata).toEqual(snapshot.tasks[0].metadata);
});
it.each([
[0, 0],
[3, 3],
+1
View File
@@ -53,6 +53,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
parentTaskId: task.parent_id ?? undefined,
metadata: task.metadata,
workspaceMode: workspaceModeFromMetadata(task.metadata),
updatedAt: task.updated_at,
isPRReview: isPRReviewFromMetadata(task.metadata),
@@ -9,6 +9,7 @@ export type MockPanel = {
group: { id: string };
isActive: boolean;
api: {
component: string;
setActive: () => void;
updateParameters: (p: Record<string, unknown>) => void;
moveTo: (opts: { group: { id: string } }) => void;
@@ -37,6 +38,7 @@ export function makeApi(
this.title = t;
},
api: {
component: add.component,
setActive() {
for (const p of panels) p.isActive = false;
panel.isActive = true;
@@ -28,6 +28,119 @@ import {
// Tests
// ---------------------------------------------------------------------------
const BROWSER_COMPONENT = "browser";
const BROWSER_GROUP = "group-browser";
const EXAMPLE_URL = "https://example.test";
const NEW_URL = `${EXAMPLE_URL}/new/`;
describe("openBrowserPanel", () => {
let api: DockviewApi;
let actions: ReturnType<typeof buildPanelActions>;
beforeEach(() => {
({ api, actions } = build(makeApi({ extraGroupIds: [BROWSER_GROUP] })));
});
it("creates and activates a Browser panel in the center group", () => {
const url = `${EXAMPLE_URL}/port-proxy/session/3000/`;
actions.openBrowserPanel(url);
const browser = api.panels.find(
(panel) => panel.api.component === BROWSER_COMPONENT,
) as unknown as MockPanel;
expect(browser).toBeDefined();
expect(browser.id).toBe(`browser:${url}`);
expect(browser.group.id).toBe(CENTER_GROUP);
expect(browser.params.url).toBe(url);
expect(browser.isActive).toBe(true);
});
it("uses the Dockview placement fallback when the stored center group is stale", () => {
const { api: staleApi, actions: staleActions, store } = build(makeApi());
store.set({ centerGroupId: "stale-center-group" });
staleActions.openBrowserPanel(NEW_URL);
const browser = staleApi.panels.find((panel) => panel.api.component === BROWSER_COMPONENT);
expect(browser?.group.id).toBe(CENTER_GROUP);
});
it("updates and focuses the active Browser panel without creating a duplicate", () => {
const oldUrl = `${EXAMPLE_URL}/old/`;
const newUrl = NEW_URL;
api.addPanel({
id: `browser:${oldUrl}`,
component: BROWSER_COMPONENT,
title: "Browser",
params: { url: oldUrl },
position: { referenceGroup: BROWSER_GROUP },
});
actions.openBrowserPanel(newUrl);
const browsers = api.panels.filter(
(panel) => panel.api.component === BROWSER_COMPONENT,
) as unknown as MockPanel[];
expect(browsers).toHaveLength(1);
const browser = browsers[0]!;
expect(browser.params.url).toBe(newUrl);
expect(browser.isActive).toBe(true);
expect(browser.group.id).toBe(BROWSER_GROUP);
});
it("prefers the active Browser panel over an earlier Browser panel", () => {
const firstUrl = `${EXAMPLE_URL}/first/`;
const secondUrl = `${EXAMPLE_URL}/second/`;
const newUrl = NEW_URL;
api.addPanel({
id: `browser:${firstUrl}`,
component: BROWSER_COMPONENT,
title: "Browser",
params: { url: firstUrl },
position: { referenceGroup: BROWSER_GROUP },
});
const second = api.addPanel({
id: `browser:${secondUrl}`,
component: BROWSER_COMPONENT,
title: "Browser",
params: { url: secondUrl },
position: { referenceGroup: CENTER_GROUP },
});
second.api.setActive();
actions.openBrowserPanel(newUrl);
expect((api.getPanel(`browser:${firstUrl}`) as unknown as MockPanel).params.url).toBe(firstUrl);
expect((api.getPanel(`browser:${secondUrl}`) as unknown as MockPanel).params.url).toBe(newUrl);
expect((api.getPanel(`browser:${secondUrl}`) as unknown as MockPanel).isActive).toBe(true);
});
it("uses the first Browser panel when another panel is active", () => {
const browserUrl = `${EXAMPLE_URL}/browser/`;
const chat = api.addPanel({
id: "chat",
component: "chat",
title: "Chat",
position: { referenceGroup: CENTER_GROUP },
});
const browser = api.addPanel({
id: `browser:${browserUrl}`,
component: BROWSER_COMPONENT,
title: "Browser",
params: { url: browserUrl },
position: { referenceGroup: BROWSER_GROUP },
});
chat.api.setActive();
actions.openBrowserPanel(NEW_URL);
const mockBrowser = browser as unknown as MockPanel;
expect(mockBrowser.params.url).toBe(NEW_URL);
expect(mockBrowser.isActive).toBe(true);
});
});
describe("addFileEditorPanel — preview behavior", () => {
let api: DockviewApi;
let actions: ReturnType<typeof buildPanelActions>;
+26 -1
View File
@@ -32,6 +32,26 @@ function addSimplePanel(api: DockviewApi, groupId: string, opts: SimplePanelOpts
focusOrAddPanel(api, { ...opts, position: { referenceGroup: groupId } });
}
function openBrowserPanel(api: DockviewApi, centerGroupId: string, url: string): void {
const browserPanel =
(api.activePanel?.api.component === "browser" ? api.activePanel : undefined) ??
api.panels.find((panel) => panel.api.component === "browser");
if (browserPanel) {
browserPanel.api.updateParameters({ url });
browserPanel.api.setActive();
return;
}
focusOrAddPanel(api, {
id: `browser:${url}`,
component: "browser",
title: t("task:browser"),
params: { url },
position: { referenceGroup: centerGroupId },
});
}
type SidePanelOpts = { groupId?: string; quiet?: boolean; inCenter?: boolean };
/**
@@ -463,10 +483,15 @@ export function buildPanelActions(set: StoreSet, get: StoreGet) {
addSimplePanel(api, groupId ?? centerGroupId, {
id: browserId,
component: "browser",
title: "Browser",
title: t("task:browser"),
params: { url: url ?? "" },
});
},
openBrowserPanel: (url: string) => {
const { api, centerGroupId } = get();
if (!api) return;
openBrowserPanel(api, centerGroupId, url);
},
promotePreviewToPinned: (type: PreviewType): void => {
const { api } = get();
if (!api) return;
+1
View File
@@ -156,6 +156,7 @@ type DockviewStore = {
addFileEditorPanel: (path: string, name: string, opts?: OpenPanelOpts) => void;
promotePreviewToPinned: (type: PreviewType) => void;
addBrowserPanel: (url?: string, groupId?: string) => void;
openBrowserPanel: (url: string) => void;
addVscodePanel: () => void;
openInternalVscode: (goto_: { file: string; line: number; col: number } | null) => void;
addPlanPanel: (opts?: { groupId?: string; quiet?: boolean; inCenter?: boolean }) => void;
@@ -101,6 +101,7 @@ export type KanbanState = {
queuedAt?: string;
isPRReview?: boolean;
isIssueWatch?: boolean;
metadata?: Record<string, unknown> | null;
isArchived?: boolean;
issueUrl?: string;
issueNumber?: number;
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { registerTasksHandlers } from "./tasks";
function makeStore() {
const task = {
id: "task-1",
workflowStepId: "step-1",
title: "Task",
position: 0,
metadata: { port_forwarding_enabled: false, unrelated: "preserve" },
};
let state = {
kanban: { workflowId: "wf-1", steps: [], tasks: [task] },
kanbanMulti: {
isLoading: false,
snapshots: {
"wf-1": {
workflowId: "wf-1",
workflowName: "Workflow",
steps: [],
tasks: [task],
},
},
},
tasks: {
activeTaskId: null,
activeSessionId: null,
pinnedSessionId: null,
lastSessionByTaskId: {},
},
taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} },
environmentIdBySessionId: {},
setActiveSession: vi.fn(),
setActiveSessionAuto: vi.fn(),
removeTaskFromSidebarPrefs: vi.fn(),
setTaskDeletedNotification: vi.fn(),
} as unknown as AppState;
return {
getState: () => state,
setState: (updater: AppState | ((s: AppState) => AppState)) => {
const next =
typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater;
state = { ...state, ...next };
},
subscribe: vi.fn(() => vi.fn()),
destroy: vi.fn(),
getInitialState: vi.fn(),
} as unknown as StoreApi<AppState> & { getState: () => AppState };
}
function taskUpdated(payload: Record<string, unknown>) {
return {
id: "message-1",
type: "notification" as const,
action: "task.updated" as const,
payload,
} as Parameters<NonNullable<ReturnType<typeof registerTasksHandlers>["task.updated"]>>[0];
}
function taskPayload(metadata: Record<string, unknown> | null | undefined) {
const payload = {
task_id: "task-1",
workflow_id: "wf-1",
workflow_step_id: "step-1",
title: "Task",
position: 0,
state: "TODO",
is_ephemeral: false,
};
return metadata === undefined ? payload : { ...payload, metadata };
}
describe("task.updated port-forwarding metadata", () => {
it("updates the preference in both kanban projections", () => {
const store = makeStore();
registerTasksHandlers(store)["task.updated"]!(
taskUpdated(taskPayload({ port_forwarding_enabled: true, unrelated: "preserve" })),
);
expect(store.getState().kanban.tasks[0]?.metadata).toEqual({
port_forwarding_enabled: true,
unrelated: "preserve",
});
expect(store.getState().kanbanMulti.snapshots["wf-1"]?.tasks[0]?.metadata).toEqual({
port_forwarding_enabled: true,
unrelated: "preserve",
});
});
it("preserves metadata when a partial task update omits it", () => {
const store = makeStore();
registerTasksHandlers(store)["task.updated"]!(taskUpdated(taskPayload(undefined)));
expect(store.getState().kanban.tasks[0]?.metadata).toEqual({
port_forwarding_enabled: false,
unrelated: "preserve",
});
});
it("allows an explicit null metadata value to clear stale state", () => {
const store = makeStore();
registerTasksHandlers(store)["task.updated"]!(taskUpdated(taskPayload(null)));
expect(store.getState().kanban.tasks[0]?.metadata).toBeNull();
});
});
+1
View File
@@ -101,6 +101,7 @@ function mergeTaskUpdate(
merged.primarySessionPendingAction = existing.primarySessionPendingAction;
}
preservePrimaryExecutorFields(existing, merged, payload);
if (!hasPayloadField(payload, "metadata")) merged.metadata = existing.metadata;
if (
!hasPayloadField(payload, "task_pending_action") &&
nextTask.taskPendingAction === undefined
+2
View File
@@ -811,6 +811,7 @@
"openFile": "Open file",
"openImage": "Open {{alt}}",
"openInAdvancedView": "Open in advanced view",
"openInBrowserPanel": "Open in browser panel",
"openInBrowserTab": "Open in browser tab",
"openInEditorNamed": "Open in {{name}}",
"openInNewTab": "Open in new tab",
@@ -859,6 +860,7 @@
"pluginTopBarActions": "Plugin top bar actions",
"portAlreadyAdded": "Port already added",
"portForwarding": "Port Forwarding",
"portForwardingPreferenceUpdateFailed": "Could not update port forwarding visibility",
"portForwardingTunnelActive_one": "Port Forwarding ({{count}} tunnel active)",
"portForwardingTunnelActive_other": "Port Forwarding ({{count}} tunnels active)",
"portNumber": "Port number",
+2
View File
@@ -811,6 +811,7 @@
"openFile": "Ōƥēń ƒĩĺē",
"openImage": "Ōƥēń {{alt}}",
"openInAdvancedView": "Ōƥēń ĩń àďvàńćēď vĩēŵ",
"openInBrowserPanel": "Ōƥēń ĩń ƀŕōŵśēŕ ƥàńēĺ",
"openInBrowserTab": "Ōƥēń ĩń ƀŕōŵśēŕ ţàƀ",
"openInEditorNamed": "Ōƥēń ĩń {{name}}",
"openInNewTab": "Ōƥēń ĩń ńēŵ ţàƀ",
@@ -859,6 +860,7 @@
"pluginTopBarActions": "Ƥĺũĝĩń ţōƥ ƀàŕ àćţĩōńś",
"portAlreadyAdded": "Ƥōŕţ àĺŕēàďŷ àďďēď",
"portForwarding": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ",
"portForwardingPreferenceUpdateFailed": "Ćōũĺď ńōţ ũƥďàţē ƥōŕţ Ƒōŕŵàŕďĩńĝ vĩśĩƀĩĺĩţŷ",
"portForwardingTunnelActive_one": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ ({{count}} ţũńńēĺ àćţĩvē)",
"portForwardingTunnelActive_other": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ ({{count}} ţũńńēĺś àćţĩvē)",
"portNumber": "Ƥōŕţ ńũḿƀēŕ",
+2
View File
@@ -798,6 +798,7 @@
"openFile": "Abrir ficheiro",
"openImage": "Abrir {{alt}}",
"openInAdvancedView": "Abrir na vista avançada",
"openInBrowserPanel": "Abrir no painel do navegador",
"openInBrowserTab": "Abrir num separador do navegador",
"openInEditorNamed": "Abrir no {{name}}",
"openInNewTab": "Abrir num novo separador",
@@ -846,6 +847,7 @@
"pluginTopBarActions": "Ações do plugin na barra superior",
"portAlreadyAdded": "Porta já adicionada",
"portForwarding": "Encaminhamento de portas",
"portForwardingPreferenceUpdateFailed": "Não foi possível atualizar a visibilidade do reencaminhamento de portas",
"portForwardingTunnelActive_one": "Encaminhamento de portas ({{count}} túnel ativo)",
"portForwardingTunnelActive_other": "Encaminhamento de portas ({{count}} túneis ativos)",
"portNumber": "Número da porta",
+1
View File
@@ -807,6 +807,7 @@
"openFile": "打开文件",
"openImage": "打开 {{alt}}",
"openInAdvancedView": "在高级视图中打开",
"openInBrowserPanel": "在浏览器面板中打开",
"openInBrowserTab": "在浏览器标签页中打开",
"openInEditorNamed": "在 {{name}} 中打开",
"openInNewTab": "在新标签页中打开",
@@ -0,0 +1,216 @@
---
spec: docs/specs/ui/port-forwarding-discovery.md
created: 2026-08-07
status: complete
---
# Implementation Plan: Task-scoped port-forwarding discovery
## Overview
Add a merge-safe, task-scoped preference mutation first, then carry the preference through the
frontend task snapshots and wire one shared visibility/open controller into the desktop and
mobile/tablet task surfaces. The existing port dialog and session-scoped tunnel APIs remain the
runtime implementation; focused component, backend, and Playwright coverage will update the current
remote-only assumptions and prove local, remote, persistence, failure, and mobile behavior.
---
## Backend
### Task metadata contract
- Add a task metadata key constant for `port_forwarding_enabled` in
`apps/backend/internal/task/models/models.go`.
- Add an authorized task-service mutation (for example,
`SetTaskPortForwardingEnabled(ctx context.Context, id string, enabled bool)`) that reads the task,
merges only the preference key, preserves all other metadata, persists the task, and publishes the
normal `task.updated` event after the write.
- Add `PATCH /api/v1/tasks/:id/port-forwarding` to
`apps/backend/internal/task/handlers/task_handlers.go` and implement its strict boolean request
parsing and `TaskDTO` response in `task_http_handlers.go`. Reuse the task service's existing
visibility authorization and error-to-status conventions.
- Leave `port.list`, `port.tunnel.list`, `port.tunnel.start`, `port.tunnel.stop`, and the existing
`/port-proxy` routes unchanged.
### Backend tests
- Add service coverage for enabling/disabling the key, preserving unrelated metadata, authorization,
and publishing an updated task event.
- Add handler coverage for valid true/false requests, malformed/non-boolean bodies, not-found access,
and the returned merged `TaskDTO`.
---
## Frontend
### Task preference propagation and API client
- Add `updateTaskPortForwarding(taskId, enabled)` to
`apps/web/lib/api/domains/kanban-api.ts` for the new HTTP route.
- Carry `metadata` through the canonical task state in
`apps/web/lib/state/slices/kanban/types.ts`, `apps/web/lib/kanban/map-task.ts`,
`apps/web/lib/ssr/mapper.ts`, and `apps/web/components/task/task-page-content-helpers.ts`.
Preserve cached metadata when a partial `task.updated` event omits it, while accepting an explicit
metadata object as authoritative.
- Add a shared task-scoped visibility controller/hook under `apps/web/components/task/` that derives
the preference from task metadata, calls the new mutation, reconciles server events, exposes
`canToggle` from active-session/agentctl readiness, and coordinates the existing dialog's open
state. Failed mutations retain the previous state and surface translated feedback.
### Desktop launcher and top bar
- Extend `apps/web/components/task/dockview-add-panel-items.tsx` with the checkable Port forwarding
row and its shared action props. Thread the active task preference/readiness and mutation handler
through `dockview-header-actions.tsx` and `dockview-watermark.tsx` so both `+` entry points behave
identically.
- Refactor `apps/web/components/task/port-forward-dialog.tsx` and
`apps/web/components/task/task-top-bar.tsx` so the top-bar control is gated by the persisted
preference plus session/agentctl readiness, not `isRemoteExecutor`. Keep the current port list,
tunnel actions, active-tunnel indicator, and dialog internals intact.
- Keep enable-from-menu behavior as “toggle and open”: persist successfully, close the menu, and open
the existing dialog through the shared controller. Disabling only changes visibility.
### Mobile and tablet parity
- Add the shared active-task Port forwarding action to
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx`; use the existing Drawer on phone
and Sheet on tablet, with an explicit labeled row and a minimum 44px touch target.
- Add the enabled top-bar control to
`apps/web/components/task/mobile/session-mobile-top-bar.tsx`, passing the shared controller through
`task-layout.tsx` and `session-mobile-layout.tsx`. When enabling from the Drawer, close the surface
before opening the dialog.
- Adapt the existing dialog presentation for phone viewport height, safe-area padding, keyboard/focus
dismissal, and one internal scroll owner. Do not add a second tunnel implementation or rely on a
hidden desktop menu as the mobile path.
### Translations
- Add all new launcher, readiness, mutation-error, and accessibility labels to the task namespace in
`apps/web/src/locales/en/task.json`, `pt-pt/task.json`, and `pseudo/task.json`; use `t()`/accessible
labels for every new user-facing string.
---
## Tests
- **What:** the backend stores the preference without replacing unrelated task metadata and emits a
task update.
**File:** `apps/backend/internal/task/service/service_port_forwarding_test.go` (or the adjacent
service test file selected by existing conventions).
**How:** use the task service/repository test fixture with existing metadata, toggle both values,
reload the task, and assert the event payload contains the merged metadata.
- **What:** the preference HTTP boundary accepts only booleans and returns the updated task.
**File:** `apps/backend/internal/task/handlers/task_port_forwarding_test.go` (or the existing HTTP
handler test file).
**How:** invoke the Gin handler with valid/invalid JSON and scoped missing-task contexts; assert
status, response metadata, and no write on invalid input.
- **What:** task snapshot and WS update mapping preserves the preference and does not clobber it when
metadata is omitted.
**File:** `apps/web/lib/kanban/map-task.test.ts` and `apps/web/lib/ws/handlers/tasks.test.ts`.
**How:** map HTTP/WS task shapes with true/false metadata, then apply an unrelated partial update
and an explicit metadata update.
- **What:** the shared preference controller rolls back on mutation failure and opens the dialog only
after a successful enable.
**File:** `apps/web/components/task/port-forwarding-visibility.test.tsx`.
**How:** mock the API and task readiness, exercise enable/disable, assert calls, checked state,
dialog-open callback, and translated error feedback.
- **What:** the desktop launcher row is checkable and appears for local and remote ready sessions,
while the top-bar control follows the saved preference.
**Files:** `apps/web/components/task/dockview-add-panel-items.test.tsx`,
`apps/web/components/task/task-top-bar.test.tsx`.
**How:** render checked/unchecked/disabled states and verify the remote executor flag does not
change eligibility.
- **What:** mobile/tablet task-switcher actions retain touch reachability and use the same controller.
**File:** `apps/web/components/task/mobile/session-mobile-layout.test.tsx` and the task-switcher
component test if one is added by the implementation.
**How:** render the active-task action, invoke it, and assert the drawer/sheet close plus dialog
callback path.
---
## E2E Tests
- **Scenario:** local task starts unchecked, then enabling from the desktop `+` menu opens the dialog
and reveals the top-bar button.
**File:** update `apps/web/e2e/tests/session/port-forward-dialog.spec.ts` and add launcher locators
to `apps/web/e2e/pages/session-page.ts`.
**What to verify:** the local button is hidden by default; the checked row appears after selection,
the dialog opens, and a reload retains the preference.
- **Scenario:** remote task uses the same preference flow instead of receiving a default visible
button solely because it is remote.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`.
**What to verify:** remote starts unchecked, enabling from the menu reveals the button, and the
existing detected/manual-port cases still pass through the unchanged dialog.
- **Scenario:** disabling leaves an active tunnel running.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`.
**What to verify:** start a tunnel, disable the launcher preference, assert the top-bar button is
hidden without a stop request, re-enable, and assert the tunnel is listed.
- **Scenario:** phone users can enable/disable from the task-switcher Drawer and use the top-bar
control without overflow.
**File:** `apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts`.
**What to verify:** the action has a 44px-class touch row, the drawer closes before the dialog
opens, the dialog stays inside the Pixel 5 viewport, and document width does not exceed viewport
width.
- **Scenario:** a not-ready agentctl session fails closed.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts` or a focused readiness fixture.
**What to verify:** the launcher entry is disabled with its accessible explanation and the top-bar
control is absent.
---
## Verification Results
- Backend focused tests: `rtk go test ./internal/task/service ./internal/task/handlers -run
'Test.*PortForward'` — passed across both packages.
- Backend suite: `rtk make -C apps/backend test` — passed.
- Frontend focused Vitest coverage for the API, mapping, WS merge, visibility controller, launcher,
top bar, and mobile layout — 65 tests passed across 7 files, plus 43 surface tests across 5 files.
- Frontend typecheck: `rtk pnpm run typecheck` — passed.
- Frontend lint: `rtk pnpm --filter @kandev/web lint` — passed.
- i18n checks: `rtk pnpm run i18n:check` and `rtk pnpm run i18n:ratchet` — passed; the catalog
checker retained its existing advisory parity warnings for incomplete locale catalogs.
- Formatting: `rtk git diff --check` — passed.
- Production web build: `rtk make build-web` — passed.
- E2E prerequisites: `rtk make build-backend` and `rtk make -C apps/backend e2e-plugin-package` —
passed.
- Desktop E2E: `rtk pnpm exec playwright test --config e2e/playwright.config.ts
e2e/tests/session/port-forward-dialog.spec.ts --project=chromium` — 12 passed.
- Mobile E2E: `rtk pnpm exec playwright test --config e2e/playwright.config.ts
e2e/tests/session/mobile-port-forwarding.spec.ts --project=mobile-chrome` — 1 passed.
- Review-fixup validation: backend port-forward tests — 9 passed across 2 packages; frontend
focused and surface coverage — 103 tests passed across 11 files; typecheck, lint, formatting,
and diff checks passed.
All implementation task files and this plan are synchronized to completed status. The E2E fixture
managed temporary repositories and exited cleanly. The not-ready agentctl transition remains an
explicit E2E coverage risk recorded in Task 04; the shared readiness gate is covered by focused
provider and launcher tests.
---
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [Task 01: Backend preference contract](task-01-backend-preference-contract.md)
Wave 2:
- [x] [Task 02: Frontend preference state](task-02-frontend-preference-state.md) (depends on Task 01)
Wave 3:
- [x] [Task 03: Desktop and mobile surfaces](task-03-task-surfaces.md) (depends on Task 02)
Wave 4:
- [x] [Task 04: Port-forwarding coverage](task-04-port-forwarding-coverage.md) (depends on Task 03)
Execution is sequential in the primary conversation; these waves do not authorize subagents.
---
## Open Questions
None. The interaction is confirmed as “toggle and open,” with a preference persisted per task.
@@ -0,0 +1,77 @@
---
id: "01-backend-preference-contract"
title: "Backend preference contract"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/port-forwarding-discovery.md"
---
# Task 01: Backend preference contract
Implement the task-scoped `port_forwarding_enabled` metadata mutation and its HTTP boundary. Keep
the existing port discovery, tunnel, proxy, and executor transport behavior unchanged.
## Acceptance
- `PATCH /api/v1/tasks/:id/port-forwarding` accepts a JSON boolean and returns the updated task.
- Toggling merges only `port_forwarding_enabled`, preserves unrelated metadata, and publishes a
normal `task.updated` event containing the merged metadata.
- Unauthorized/missing tasks and malformed bodies fail with the status/error behavior in the spec;
no invalid request mutates the task.
## Verification
- `cd apps/backend && go test ./internal/task/service ./internal/task/handlers -run 'Test.*PortForward'`
- `make -C apps/backend test` (if the focused tests pass and the repository test target is available)
## Files likely touched
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/service/service_workflow.go` or a task-scoped service file
- `apps/backend/internal/task/service/service_port_forwarding_test.go`
- `apps/backend/internal/task/handlers/task_handlers.go`
- `apps/backend/internal/task/handlers/task_http_handlers.go`
- `apps/backend/internal/task/handlers/task_port_forwarding_test.go`
## Dependencies
None.
## Parallelism
Sequential. The task owns the backend contract consumed by every later task.
## Inputs
- Spec sections: Data model, API surface, Permissions, Failure modes.
- Existing `Service.UpdateTask` authorization/event conventions and `TaskDTO` conversion.
- Existing task HTTP route registration and handler test fixtures.
## Output contract
Report the implementation summary, actual files changed, exact test commands/results, any event or
authorization caveat, and synchronized task/plan status. Do not modify the frontend in this task.
## Results
- Added `MetaKeyPortForwardingEnabled` and a merge-preserving, authorized service mutation that
publishes the normal `task.updated` event.
- Added `PATCH /api/v1/tasks/:id/port-forwarding` with strict boolean payload validation and
updated-task response conversion; the handler also rejects trailing JSON values.
- Added service and HTTP coverage for enable, disable, metadata preservation, malformed payloads,
and missing tasks.
- Changed files:
- `apps/backend/internal/task/handlers/task_handlers.go`
- `apps/backend/internal/task/handlers/task_http_handlers.go`
- `apps/backend/internal/task/handlers/task_port_forwarding_test.go`
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/service/service_port_forwarding_test.go`
- `apps/backend/internal/task/service/service_workflow.go`
- Event/authorization caveat: no caveat; the mutation uses the existing task visibility
authorization and publishes after a successful repository write.
- Verification: `rtk go test ./internal/task/service ./internal/task/handlers -run 'Test.*PortForward'`
— passed (9 tests across 2 packages).
- Verification: `rtk make -C apps/backend test` — passed.
- Synchronized status: Task 01 and the implementation plan are marked completed.
@@ -0,0 +1,100 @@
---
id: "02-frontend-preference-state"
title: "Frontend preference state"
status: completed
wave: 2
depends_on: ["01-backend-preference-contract"]
plan: "plan.md"
spec: "../../specs/ui/port-forwarding-discovery.md"
---
# Task 02: Frontend preference state
Thread task metadata and the new mutation through the canonical web task state, then provide one
shared controller for desktop, tablet, and phone surfaces. Do not change the visual launcher or
top-bar composition yet.
## Acceptance
- HTTP/SSR/WS task snapshots expose `metadata.port_forwarding_enabled` without losing it on partial
task updates.
- The shared controller reports preference/readiness, performs the new API mutation, reconciles
`task.updated`, and rolls back failed writes with translated feedback.
- The controller has an explicit open-dialog callback/state so a successful enable can implement
“toggle and open” without each viewport duplicating mutation logic.
## Verification
- If dependencies are absent: `cd apps && pnpm install --frozen-lockfile`
- `cd apps && pnpm --filter @kandev/web test -- lib/kanban/map-task.test.ts lib/ws/handlers/tasks.test.ts components/task/port-forwarding-visibility.test.tsx`
- `cd apps/web && pnpm run typecheck`
## Files likely touched
- `apps/web/lib/api/domains/kanban-api.ts`
- `apps/web/lib/state/slices/kanban/types.ts`
- `apps/web/lib/kanban/map-task.ts`
- `apps/web/lib/kanban/map-task.test.ts`
- `apps/web/lib/ssr/mapper.ts`
- `apps/web/components/task/task-page-content-helpers.ts`
- `apps/web/lib/ws/handlers/tasks.ts`
- `apps/web/lib/ws/handlers/tasks.test.ts`
- `apps/web/components/task/port-forwarding-visibility.tsx`
- `apps/web/components/task/port-forwarding-visibility.test.tsx`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/pt-pt/task.json`
- `apps/web/src/locales/pseudo/task.json`
## Dependencies
Task 01.
## Parallelism
Sequential. The shared controller and canonical task mapping are common state for all UI surfaces.
## Inputs
- Spec sections: What, Data model, API surface, State machine, Failure modes, Persistence guarantees.
- Backend route and event contract from Task 01.
- Existing `toKanbanTask`, SSR snapshot mapping, and partial task-event merge rules.
## Output contract
Report the summary, actual files changed, exact focused test/typecheck results, event-reconciliation
behavior, and synchronized task/plan status. Leave the launcher and top-bar rendering for Task 03.
## Results
- Added the typed preference mutation API and carried task metadata through kanban state, SSR
hydration, task-page resolution, and partial WebSocket task updates.
- Added a shared visibility provider with readiness gating, optimistic writes, rollback feedback,
task-change reconciliation, and successful-enable dialog opening.
- Added translated failure feedback in the English, pseudo-locale, and Portuguese locale files.
- Changed files:
- `apps/web/components/task/port-forwarding-visibility-provider.test.tsx`
- `apps/web/components/task/port-forwarding-visibility-provider.tsx`
- `apps/web/components/task/port-forwarding-visibility.test.ts`
- `apps/web/components/task/port-forwarding-visibility.ts`
- `apps/web/components/task/task-page-content-helpers.test.ts`
- `apps/web/components/task/task-page-content-helpers.ts`
- `apps/web/lib/api/domains/kanban-api.test.ts`
- `apps/web/lib/api/domains/kanban-api.ts`
- `apps/web/lib/kanban/map-task.test.ts`
- `apps/web/lib/kanban/map-task.ts`
- `apps/web/lib/ssr/mapper.test.ts`
- `apps/web/lib/ssr/mapper.ts`
- `apps/web/lib/state/slices/kanban/types.ts`
- `apps/web/lib/ws/handlers/tasks-port-forwarding.test.ts`
- `apps/web/lib/ws/handlers/tasks.ts`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/pseudo/task.json`
- `apps/web/src/locales/pt-pt/task.json`
- Event reconciliation: an explicit metadata object replaces the cached metadata, while an omitted
metadata field preserves the existing preference.
- Verification: `rtk pnpm exec vitest run lib/api/domains/kanban-api.test.ts lib/kanban/map-task.test.ts
lib/ssr/mapper.test.ts lib/ws/handlers/tasks-port-forwarding.test.ts
components/task/task-page-content-helpers.test.ts components/task/port-forwarding-visibility.test.ts
components/task/port-forwarding-visibility-provider.test.tsx` — passed (65 tests across 7 files).
- Verification: `rtk pnpm run typecheck` — passed.
- Synchronized status: Task 02 and the implementation plan are marked completed.
@@ -0,0 +1,88 @@
---
id: "03-task-surfaces"
title: "Desktop and mobile surfaces"
status: completed
wave: 3
depends_on: ["02-frontend-preference-state"]
plan: "plan.md"
spec: "../../specs/ui/port-forwarding-discovery.md"
---
# Task 03: Desktop and mobile surfaces
Expose the shared controller through the desktop Dockview launcher, watermark launcher, task
top-bar button, and the phone/tablet task-switcher and top-bar compositions. Preserve the existing
Port Forwarding dialog's runtime behavior while making its presentation phone-safe.
## Acceptance
- Desktop `+` and empty-group menus show a checkable Port forwarding row; enabling it persists then
opens the existing dialog, and disabling it hides the top-bar button without stopping tunnels.
- Local and remote ready sessions have identical eligibility; the old remote-only gate is gone.
- Phone Drawer and tablet Sheet provide a labeled active-task action with a touch-sized row, and the
enabled mobile top bar can open the same dialog without horizontal overflow or a second scroll owner.
- All new user-facing copy is translated in the task namespace and new controls have accessible
labels/test IDs.
## Verification
- If dependencies are absent: `cd apps && pnpm install --frozen-lockfile`
- `cd apps && pnpm --filter @kandev/web test -- components/task/dockview-add-panel-items.test.tsx components/task/task-top-bar.test.tsx components/task/mobile/session-mobile-layout.test.tsx`
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/components/task/dockview-add-panel-items.tsx`
- `apps/web/components/task/dockview-add-panel-items.test.tsx`
- `apps/web/components/task/dockview-header-actions.tsx`
- `apps/web/components/task/dockview-watermark.tsx`
- `apps/web/components/task/port-forward-dialog.tsx`
- `apps/web/components/task/task-top-bar.tsx`
- `apps/web/components/task/task-top-bar.test.tsx`
- `apps/web/components/task/task-page-inner.tsx`
- `apps/web/components/task/task-layout.tsx`
- `apps/web/components/task/mobile/session-mobile-layout.tsx`
- `apps/web/components/task/mobile/session-mobile-layout.test.tsx`
- `apps/web/components/task/mobile/session-mobile-top-bar.tsx`
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/pt-pt/task.json`
- `apps/web/src/locales/pseudo/task.json`
## Dependencies
Task 02.
## Parallelism
Sequential. Desktop and mobile composition share the controller and dialog lifecycle.
## Inputs
- Spec sections: What, State machine, Failure modes, Scenarios.
- Mobile parity guide: use the existing Drawer/Sheet distinction, 44px rows, safe areas, and one
internal scroll owner.
- Existing `PortForwardButton` and `PortForwardDialogContent` behavior.
## Output contract
Report the summary, actual files changed, focused component/typecheck/lint results, mobile geometry
evidence, and synchronized task/plan status. Do not add E2E coverage in this task.
## Results
- Added a checkable Port Forwarding item to Dockview's plus and empty-group launchers, using the
shared preference controller for readiness, persistence, and toggle-and-open behavior.
- Removed the remote-only top-bar gate so local and remote ready sessions share the same control;
disabling visibility closes only the control/dialog and leaves runtime tunnels untouched.
- Added the enabled control to the phone top bar and a 44px active-task action in the phone Drawer
and tablet Sheet; the existing dialog now uses dynamic viewport bounds and one scroll owner.
- Hardened the Dockview launchers for routes without the task provider, ignored stale tunnel-list
responses after session changes, and reset the dialog when agentctl readiness is lost.
- Verification: `rtk pnpm exec vitest run components/task/dockview-add-panel-items.test.tsx
components/task/task-top-bar.test.tsx components/task/mobile/session-mobile-layout.test.tsx
components/task/port-forwarding-visibility-provider.test.tsx components/task/port-forward-dialog.test.tsx`
— passed (43 tests across 5 files).
- Verification: `rtk pnpm run typecheck` — passed.
- Verification: `rtk pnpm --filter @kandev/web lint` — passed.
@@ -0,0 +1,88 @@
---
id: "04-port-forwarding-coverage"
title: "Port-forwarding coverage"
status: completed
wave: 4
depends_on: ["03-task-surfaces"]
plan: "plan.md"
spec: "../../specs/ui/port-forwarding-discovery.md"
---
# Task 04: Port-forwarding coverage
Update the existing desktop port-forward E2E coverage to exercise the new preference flow and add a
phone-native test for the Drawer/top-bar path. Keep the existing detected/manual port and proxy URL
assertions as regression coverage for the unchanged runtime dialog.
## Acceptance
- Desktop local and remote sessions start with the preference off, can enable it from the launcher,
open the dialog, and retain the preference after reload.
- Disabling visibility does not send a tunnel-stop request and re-enabling lists the still-active
tunnel.
- Phone can reach the action from the task-switcher Drawer, opens the dialog after enabling, meets
viewport/touch/safe-area expectations, and has no horizontal document overflow.
- The not-ready path is visibly disabled/hidden as specified.
## Verification
- If dependencies are absent: `cd apps && pnpm install --frozen-lockfile`
- `cd apps/web && pnpm e2e -- tests/session/port-forward-dialog.spec.ts --project=chromium`
- `cd apps/web && pnpm e2e -- tests/session/mobile-port-forwarding.spec.ts --project=mobile-chrome`
- Record any required scoped backend fixture command and cleanup evidence in `## Results`.
## Files likely touched
- `apps/web/e2e/pages/session-page.ts`
- `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`
- `apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts`
## Dependencies
Task 03.
## Parallelism
Sequential. These tests depend on the shipped HTTP, state, and responsive surfaces.
## Inputs
- Spec scenarios and the existing `port-forward-dialog.spec.ts` seed helpers.
- Existing mobile-chrome naming convention and task-switcher Drawer geometry assertions.
## Output contract
Report exact E2E commands, project/test counts, any fixture teardown, changed files, remaining risks,
and synchronized task/plan status. Do not mark the feature complete without updating the plan's
Verification Results.
## Results
Implemented the launcher-driven preference flow in the existing desktop and mobile E2E fixtures.
The desktop coverage now verifies local and remote opt-in, disable/re-enable without stopping an
active tunnel, reload persistence, and the existing dialog port/proxy behavior. The mobile coverage
verifies the phone Drawer action, dialog opening, viewport containment, and no document-level
horizontal overflow.
Verification:
- `rtk make build-web` — passed.
- `rtk make build-backend` — passed; built the backend, mock agent, and agentctl fixtures.
- `rtk make -C apps/backend e2e-plugin-package` — passed; wrote the E2E plugin package.
- `rtk pnpm exec playwright test --config e2e/playwright.config.ts e2e/tests/session/port-forward-dialog.spec.ts --project=chromium` — 12 passed in 1 minute.
- `rtk pnpm exec playwright test --config e2e/playwright.config.ts e2e/tests/session/mobile-port-forwarding.spec.ts --project=mobile-chrome` — 1 passed in 6.9 seconds.
The fixture-managed E2E runs exited cleanly and created their temporary repositories under `/tmp`.
Changed E2E files: `e2e/pages/session-page.ts`, `e2e/tests/session/port-forward-dialog.spec.ts`,
and `e2e/tests/session/mobile-port-forwarding.spec.ts`.
Not-ready path evidence: the focused launcher/provider coverage verifies the disabled `canToggle`
state, but these E2E runs do not simulate an agentctl readiness loss, so the not-ready E2E scenario
remains uncovered.
Remaining risks: a dedicated E2E fixture is still needed to exercise the live agentctl-not-ready
transition and its accessible disabled-state explanation. The implementation fails closed through
the shared readiness gate, and the covered unit/surface tests continue to verify that behavior.
Synchronized status: Task 04 and the implementation plan remain marked completed; the uncovered
scenario is recorded explicitly as a validation risk rather than claimed as E2E coverage.
+162
View File
@@ -0,0 +1,162 @@
---
spec: docs/specs/port-proxy-browser-panel/spec.md
created: 2026-08-07
status: complete
---
# Implementation Plan: Open proxy URLs in the browser panel
## Overview
Add a Dockview action that reuses an existing Browser panel or creates one in the central group.
Update the Browser panel when Dockview parameters change. Add the action to proxy URL rows only,
close the Port Forwarding dialog after a successful navigation, and keep the existing system
browser action as the phone/tablet fallback. No backend or port-forwarding protocol change is
needed.
The work is sequential: implement and test the Dockview navigation action, wire the dialog and
translations, then add desktop and mobile Playwright coverage.
---
## Backend
No backend changes. The feature uses the existing proxy URL and does not add an HTTP route,
WebSocket message, database field, or authorization rule.
---
## Frontend
### Dockview browser navigation
- `apps/web/lib/state/dockview-panel-actions.ts`: add an `openBrowserPanel(url)` action. Prefer the
active panel when its component is `browser`; otherwise use the first Browser panel in
`api.panels`. For an existing panel, call `updateParameters({ url })` and `setActive()`. If no
panel exists, call `focusOrAddPanel` with a `browser:<url>` ID, the `browser` component, the URL
parameter, and `centerGroupId` placement.
- `apps/web/lib/state/dockview-store.ts`: expose `openBrowserPanel` in the store type through the
existing `buildPanelActions` spread.
- `apps/web/lib/state/dockview-panel-actions.test-utils.ts` and
`apps/web/lib/state/dockview-panel-actions.test.ts`: extend the Dockview mock with component
identity and test existing-panel reuse, active-panel preference, central placement, URL
parameters, and no duplicate panel creation.
### Browser panel URL synchronization
- `apps/web/components/task/browser-panel.tsx`: synchronize the local address value and draft with
a changed `params.url`. Keep the existing detected preview URL fallback and iframe loading rules.
Add a stable `data-testid` to the Browser panel root for Playwright assertions if the existing
panel primitives do not provide one.
### Port Forwarding dialog action
- `apps/web/components/task/port-forward-dialog.tsx`: add an optional browser-panel callback to
`UrlActions`, pass it only to the proxy `PortUrlRow`, and render an accessible Browser-panel icon
action beside copy and system-browser actions. Read the Dockview action from the store only when
the desktop Dockview API is available. Invoke the callback and close the dialog after navigation.
Keep the action out of tunnel rows.
- Keep URL action controls usable with coarse pointers. Use a 44px hit area for the shared mobile
fallback controls and the compact existing desktop size through responsive classes. Keep one
internal dialog scroll owner and preserve the current viewport containment.
- `apps/web/src/locales/en/task.json`, `apps/web/src/locales/pt-pt/task.json`,
`apps/web/src/locales/zh-cn/task.json`, and `apps/web/src/locales/pseudo/task.json`: add the
translated `openInBrowserPanel` label. All new accessible copy and tooltip text uses `t()`.
### Mobile and tablet behavior
- Do not mount or add a new Dockview group to phone/tablet layouts. The Dockview action is absent
when the Dockview API is unavailable.
- Keep the existing system browser action visible and touch-sized in the shared Port Forwarding
dialog. This is the mobile/tablet path to the same proxy URL.
- Use the existing Port Forwarding dialog, its internal scroll region, and the existing mobile task
drawer entry. Do not add a second port or tunnel state model.
---
## Tests
- **What:** an existing Browser panel is updated and focused, and no new panel is created.
**File:** `apps/web/lib/state/dockview-panel-actions.test.ts`.
**How:** use the current Dockview test mock; seed Browser panels in custom groups, call
`openBrowserPanel`, and assert URL parameters, active state, group identity, and panel count.
- **What:** no Browser panel creates one in the central group with the requested URL.
**File:** `apps/web/lib/state/dockview-panel-actions.test.ts`.
**How:** call the action on an empty mock API and assert the added panel's component, ID,
`params.url`, group, and active state.
- **What:** a changed Dockview URL parameter updates the Browser panel address and navigation
source without changing the detected-preview fallback.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`.
**How:** open a proxy URL in an existing Browser panel and assert the address input updates;
open a proxy URL in a new Browser panel and assert the iframe source uses the proxy URL.
- **What:** the dialog exposes the action only for proxy rows and keeps the existing actions.
**File:** `apps/web/components/task/port-forward-dialog.test.tsx` or the closest focused dialog
test harness.
**How:** render the row with a mocked Dockview action, invoke the Browser-panel action, and
assert the callback and dialog close path; render a tunnel row and assert no Browser-panel action.
- **What:** new translation keys pass catalog checks and changed files pass frontend type and lint
rules.
**Files:** locale files and changed frontend files.
**How:** run the focused Vitest commands, `cd apps/web && pnpm run typecheck`,
`cd apps && pnpm --filter @kandev/web lint`, `cd apps && pnpm run i18n:check`, and
`cd apps && pnpm run i18n:ratchet`.
---
## E2E Tests
- **Scenario:** GIVEN no Browser panel, WHEN a user adds a manual port and clicks the proxy row's
Browser-panel action, THEN a Browser panel is active in the central group, its address input
contains the proxy URL, its iframe has the proxy URL, and the dialog is closed.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`.
**Page object:** add proxy Browser-panel action and Browser panel locators to
`apps/web/e2e/pages/session-page.ts`.
- **Scenario:** GIVEN an existing Browser panel in a group, WHEN the user opens a different proxy
URL in the Browser panel, THEN the same Browser panel remains and its URL changes.
**File:** `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`.
**What to verify:** Dockview reports one Browser component, the panel is active, and the address
input contains the new URL.
- **Scenario:** GIVEN a phone viewport, WHEN the user opens the Port Forwarding dialog, THEN the
existing system-browser fallback remains reachable, the dialog stays within the viewport, and
document horizontal overflow is zero.
**File:** `apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts`.
**What to verify:** add a manual row, inspect its system-browser link, and run the existing
containment assertions.
---
## Verification Results
- `cd apps && pnpm --filter @kandev/web test -- lib/state/dockview-panel-actions.test.ts components/task/port-forward-dialog.test.tsx` — 33 tests passed.
- `cd apps/web && pnpm run typecheck` — passed.
- `cd apps && pnpm --filter @kandev/web lint` — passed with zero warnings.
- `cd apps && pnpm --filter @kandev/web i18n:check` — passed; existing real-locale parity warnings remain advisory.
- `cd apps && pnpm --filter @kandev/web i18n:ratchet` — passed.
- `cd apps && pnpm --filter @kandev/web build:e2e` — passed.
- `cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/session/port-forward-dialog.spec.ts` — 14 tests passed.
- `cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/session/mobile-port-forwarding.spec.ts` — 1 test passed.
- `git diff --check` — passed.
---
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [Task 01: Dockview browser navigation](task-01-dockview-browser-navigation.md)
Wave 2:
- [x] [Task 02: Port dialog browser action](task-02-port-dialog-browser-action.md) (depends on
Task 01)
Wave 3:
- [x] [Task 03: Browser navigation E2E coverage](task-03-browser-navigation-e2e.md) (depends on
Task 02)
Execution is sequential in the primary conversation. These waves do not authorize subagents.
## Open Questions
None. The desktop group behavior and the phone/tablet fallback are defined in the spec.
@@ -0,0 +1,45 @@
---
id: "01-dockview-browser-navigation"
title: "Dockview browser navigation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/port-proxy-browser-panel/spec.md"
---
# Task 01: Dockview browser navigation
- **Acceptance:**
- `openBrowserPanel(url)` updates and focuses the active Browser panel, or the first Browser
panel when no Browser panel is active.
- When no Browser panel exists, the action adds one in the central group with `params.url` set
to `url` and makes it active.
- The Browser panel updates its address input and iframe source when Dockview changes `params.url`.
- **Verification:**
- `cd apps && pnpm --filter @kandev/web test -- lib/state/dockview-panel-actions.test.ts`
- `cd apps/web && pnpm run typecheck`
- **Files likely touched:**
- `apps/web/lib/state/dockview-panel-actions.ts`
- `apps/web/lib/state/dockview-store.ts`
- `apps/web/lib/state/dockview-panel-actions.test-utils.ts`
- `apps/web/lib/state/dockview-panel-actions.test.ts`
- `apps/web/components/task/browser-panel.tsx`
- `apps/web/components/task/browser-panel.test.tsx`
- **Dependencies:** None.
- **Parallelism:** sequential.
- **Inputs:** Spec state/data ownership, API surface, and first three scenarios. Use
`focusOrAddPanel`, `api.panels`, `panel.api.component`, `panel.api.updateParameters`, and the
existing panel portal parameter forwarding.
- **Output contract:** Summary, exact files changed, focused test results, typecheck result,
blockers, and updated task/plan status.
## Results
- Added `openBrowserPanel(url)` to the Dockview actions and store contract.
- Reuses the active Browser panel, then the first Browser panel, and otherwise creates one in the
central group with a stale-group fallback.
- Synchronizes Browser panel address state when Dockview changes `params.url`.
- `cd apps && pnpm --filter @kandev/web test -- lib/state/dockview-panel-actions.test.ts` — 31 tests passed.
- `cd apps/web && pnpm run typecheck` — passed.
- Browser parameter synchronization is covered by the desktop E2E scenarios in Task 03.
@@ -0,0 +1,49 @@
---
id: "02-port-dialog-browser-action"
title: "Port dialog browser action"
status: done
wave: 2
depends_on: ["01-dockview-browser-navigation"]
plan: "plan.md"
spec: "../../specs/port-proxy-browser-panel/spec.md"
---
# Task 02: Port dialog browser action
- **Acceptance:**
- Proxy URL rows show an accessible **Open in browser panel** action when Dockview is available;
tunnel URL rows do not show it.
- Clicking the action navigates through the Dockview action and closes the Port Forwarding dialog.
- Copy and system-browser actions remain available, all new copy is translated, and coarse-pointer
URL controls have a touch-safe hit area.
- **Verification:**
- `cd apps && pnpm --filter @kandev/web test -- components/task/port-forward-dialog.test.tsx`
- `cd apps && pnpm --filter @kandev/web lint`
- `cd apps && pnpm run i18n:check`
- `cd apps && pnpm run i18n:ratchet`
- `cd apps/web && pnpm run typecheck`
- **Files likely touched:**
- `apps/web/components/task/port-forward-dialog.tsx`
- `apps/web/components/task/port-forward-dialog.test.tsx`
- `apps/web/src/locales/en/task.json`
- `apps/web/src/locales/pt-pt/task.json`
- `apps/web/src/locales/zh-cn/task.json`
- `apps/web/src/locales/pseudo/task.json`
- **Dependencies:** Task 01.
- **Parallelism:** sequential.
- **Inputs:** Spec What, failure modes, mobile fallback, and scenarios four through six. Reuse the
existing Port Forwarding dialog and `useDockviewStore`; do not add a backend or port API call.
- **Output contract:** Summary, exact files changed, focused test/lint/i18n/typecheck results,
blockers, and updated task/plan status.
## Results
- Added the translated Browser-panel action to proxy URL rows only.
- Kept copy and system-browser actions on proxy and tunnel rows, and made URL controls touch-safe
on coarse pointers.
- The action closes the dialog after invoking the Dockview navigation action.
- `cd apps && pnpm --filter @kandev/web test -- components/task/port-forward-dialog.test.tsx` — 2 tests passed.
- `cd apps && pnpm --filter @kandev/web lint` — passed with zero warnings.
- `cd apps && pnpm run i18n:check` — passed; existing real-locale parity warnings remain advisory.
- `cd apps && pnpm run i18n:ratchet` — passed.
- `cd apps/web && pnpm run typecheck` — passed.
@@ -0,0 +1,42 @@
---
id: "03-browser-navigation-e2e"
title: "Browser navigation E2E coverage"
status: done
wave: 3
depends_on: ["02-port-dialog-browser-action"]
plan: "plan.md"
spec: "../../specs/port-proxy-browser-panel/spec.md"
---
# Task 03: Browser navigation E2E coverage
- **Acceptance:**
- Desktop E2E proves a proxy URL opens a new central Browser panel and navigates its address and
iframe.
- Desktop E2E proves an existing Browser panel is reused without creating a duplicate.
- Mobile E2E proves the system-browser fallback remains reachable and the Port Forwarding dialog
has no viewport or horizontal-overflow regression.
- **Verification:**
- `cd apps/web && pnpm exec playwright test --config e2e/playwright.config.ts e2e/tests/session/port-forward-dialog.spec.ts --project=chromium`
- `cd apps/web && pnpm exec playwright test --config e2e/playwright.config.ts e2e/tests/session/mobile-port-forwarding.spec.ts --project=mobile-chrome`
- `cd apps && pnpm --filter @kandev/web build:vite`
- `cd apps/web && pnpm run typecheck`
- **Files likely touched:**
- `apps/web/e2e/pages/session-page.ts`
- `apps/web/e2e/tests/session/port-forward-dialog.spec.ts`
- `apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts`
- **Dependencies:** Task 02.
- **Parallelism:** sequential.
- **Inputs:** Spec scenarios one, two, and five. Use the existing session seed helpers, Dockview
window test API, proxy URL row test IDs, and mobile viewport assertions. Rebuild the production
web bundle before E2E execution.
- **Output contract:** Summary, exact files changed, build and E2E commands with pass counts,
cleanup evidence, blockers, and updated task/plan status.
## Results
- Added desktop coverage for new central Browser panel creation and existing Browser panel reuse.
- Added mobile coverage for the system-browser fallback and absence of the Dockview-only action.
- `cd apps && pnpm --filter @kandev/web build:e2e` — passed.
- `cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/session/port-forward-dialog.spec.ts` — 14 tests passed.
- `cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/session/mobile-port-forwarding.spec.ts` — 1 test passed.
+2
View File
@@ -178,6 +178,8 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped |
| [adaptive-kanban](ui/adaptive-kanban.md) | shipped |
| [task-layout-profiles](ui/task-layout-profiles.md) | draft |
| [port-forwarding-discovery](ui/port-forwarding-discovery.md) | building |
| [port-proxy-browser-panel](port-proxy-browser-panel/spec.md) | shipped |
| [task-surface-refresh](ui/task-surface-refresh.md) | draft |
| [walkthrough-navigation-layout](walkthrough-navigation-layout/spec.md) | shipped |
| [walkthrough-feedback-controls](walkthrough-feedback-controls/spec.md) | shipped |
+100
View File
@@ -0,0 +1,100 @@
---
status: shipped
created: 2026-08-07
owner: kandev
---
# Open proxy URLs in the browser panel
## Why
Users can copy a proxy URL or open it in the system browser. They cannot send the URL directly to
the Kandev Browser panel. This adds work when the user already has a Browser panel in the task
workbench.
## What
- Each proxy URL row in the Port Forwarding dialog has an **Open in browser panel** action on the
desktop workbench.
- The action is present for proxy URLs only. Tunnel URL rows keep the current copy and system
browser actions.
- When a Browser panel exists in any Dockview group, the action reuses that panel. It focuses the
panel, replaces its address value with the proxy URL, and loads the proxy URL.
- When more than one Browser panel exists, the active Browser panel is reused. If no Browser panel
is active, the first Browser panel in Dockview panel order is reused.
- When no Browser panel exists, the action creates one in the current central group, focuses it,
inserts the proxy URL, and loads the proxy URL.
- The Port Forwarding dialog closes after the action succeeds. This makes the focused Browser panel
visible.
- The action does not create a second Browser panel when one already exists.
- The existing copy action and system browser action continue to work.
- The action has an accessible name and a tooltip. Its desktop icon button has a touch-safe hit area
when the dialog is used with a coarse pointer.
- Phone and tablet layouts do not have Dockview groups. They do not show this Dockview-only action.
The existing system browser action remains available as the mobile fallback for the same proxy
URL.
## State and data ownership
This feature adds no persistent data, database field, HTTP route, or WebSocket message.
- `PortForwardDialog` derives the proxy URL from the session ID and port.
- The Dockview store owns the current Dockview API and central group ID.
- The Browser panel owns its current URL in the Dockview panel parameter `params.url`. A parameter
update is forwarded to the existing Browser panel portal, which updates its address field and
iframe navigation.
- Existing Dockview environment-layout persistence may save the panel and its URL in the current
browser session. This feature adds no new persistence rule. The URL is no longer available when
the existing session-scoped proxy is no longer available.
## API surface
No new backend API is required. The feature reuses the existing session-scoped proxy URL and the
existing Dockview client API:
- `DockviewApi.panels` and `DockviewPanelApi.setActive()` locate and focus a Browser panel.
- `DockviewPanelApi.updateParameters({ url })` sends a new URL to an existing Browser panel.
- `DockviewApi.addPanel(...)` creates a Browser panel in the central group when needed.
## Failure modes
- If the Dockview API is not available, the Dockview-only action is not rendered. The existing copy
and system browser actions remain available.
- If the central group ID is stale, the existing Dockview placement fallback selects a valid group.
- If the proxy page cannot load, the Browser panel still shows the requested URL and keeps the
existing Browser panel loading behavior. The action does not retry a page load or show a new
port-forwarding error.
## Persistence guarantees
- No feature-specific state survives a Kandev restart.
- If the existing Dockview environment layout saves the Browser panel, its URL follows that existing
session-storage behavior. The feature does not copy the URL to the backend or to another task.
- Port discovery, tunnel state, and proxy authorization keep their current lifecycle.
## Scenarios
- **GIVEN** a desktop task with no Browser panel, **WHEN** the user clicks **Open in browser panel**
on a proxy URL row, **THEN** one Browser panel opens in the central group, becomes active, shows
the proxy URL in its address input, loads the URL, and the Port Forwarding dialog closes.
- **GIVEN** a desktop task with a Browser panel in a non-central group, **WHEN** the user clicks the
action for a different proxy URL, **THEN** the existing panel remains in its group, becomes
active, shows the new URL, and loads the new URL.
- **GIVEN** a desktop task with two Browser panels and one active Browser panel, **WHEN** the user
clicks the action, **THEN** the active Browser panel is updated and no new panel is created.
- **GIVEN** a proxy URL row and a tunnel URL row, **WHEN** the user views their actions, **THEN**
only the proxy row has **Open in browser panel** and both rows have copy and system browser
actions.
- **GIVEN** a phone or tablet task, **WHEN** the user opens the Port Forwarding dialog, **THEN**
the existing system browser action remains reachable, the dialog stays inside the viewport, and
the document has no horizontal overflow.
- **GIVEN** the Dockview API is unavailable, **WHEN** the user views the Port Forwarding dialog,
**THEN** the Dockview-only action is absent and the existing copy and system browser actions are
still usable.
## Out of scope
- Adding a Browser panel to the phone layout or changing the tablet preview layout.
- Adding browser history, multiple URL tabs, or a new proxy transport.
- Changing proxy URL generation, tunnel behavior, port discovery, or authorization.
- Opening the proxy URL in a new system browser tab from the new action.
+161
View File
@@ -0,0 +1,161 @@
---
status: building
created: 2026-08-07
owner: kandev
---
# Task-scoped port-forwarding discovery
## Why
People who host Kandev on a home machine and access it remotely need the services running in a
task workspace to be reachable without knowing where the existing tunnel control is hidden. The
current control is easy to miss and is gated by executor type, so local-executor tasks do not offer
the capability even when their Kandev host is being accessed remotely.
## What
- The desktop task `+` launcher includes a checkable **Port forwarding** entry for the active task.
It uses the existing port-forwarding capability; it does not create a Dockview panel.
- The phone task-switcher drawer and the tablet task-switcher sheet expose the same active-task
action with the same checked state, so the feature does not depend on a desktop-only menu.
- The entry is available for both local and remote executors when the active task has a session and
its agentctl is ready. It is visible but disabled, with an explanatory accessible label, while
the session or agentctl is unavailable.
- Selecting an unchecked entry persists the task preference, reveals the existing port-forwarding
control in the task top bar, and opens the existing Port Forwarding dialog. The dialog continues
to list detected/manual ports and start, stop, and link active tunnels using its current behavior.
- Selecting a checked entry persists the preference as disabled and hides the top-bar control. It
does not stop, delete, or otherwise change active tunnels. Re-enabling the preference exposes the
same active tunnels again when the dialog refreshes.
- The top-bar control is rendered on desktop and mobile whenever the persisted preference is enabled
and the active session/agentctl is ready. Clicking it opens the existing dialog; stopping the
last tunnel does not hide the control.
- The preference is per task, not per browser, executor, or session. New tasks default to disabled.
- A failed preference mutation leaves the previous checked state in place, does not open the dialog
for an enable attempt, and surfaces an actionable error through the existing task UI feedback.
## Data model
The existing task metadata JSON object stores one product-owned key:
| Entity | Field | Type | Meaning |
| --- | --- | --- | --- |
| `tasks` | `metadata.port_forwarding_enabled` | optional boolean | Whether this task should expose its port-forwarding control. Missing and `false` both mean disabled. |
The key is persisted in the existing task metadata column and is included in the existing `TaskDTO`
and `task.updated` payload. Updating it MUST merge the key into the current metadata object and
preserve unrelated metadata keys. No schema migration or new task relationship is introduced.
The detected-port and tunnel records remain the existing session-scoped runtime state. This feature
does not make tunnels durable and does not change their current cleanup behavior when the backend or
session ends.
## API surface
Add the task-scoped preference mutation:
`PATCH /api/v1/tasks/:id/port-forwarding`
Request body:
```json
{ "enabled": true }
```
The body MUST contain a JSON boolean. A successful request returns the updated `TaskDTO` with the
merged metadata and publishes the normal `task.updated` event containing the same metadata. A
malformed body returns `400`; an inaccessible or missing task returns `404`; a persistence failure
returns `500`.
The existing port-forwarding WebSocket actions remain the runtime contract:
- `port.list`
- `port.tunnel.list`
- `port.tunnel.start`
- `port.tunnel.stop`
The existing session-scoped port-proxy URLs and their authorization rules are unchanged. No new
WebSocket action is required for the preference.
## State machine
| State | Trigger | Result |
| --- | --- | --- |
| Disabled | Task has no preference or `port_forwarding_enabled=false` | No task top-bar control is rendered; the launcher entry is unchecked. |
| Persisting enabled | User selects the unchecked launcher entry | The mutation is sent while the previous UI state is retained until success. |
| Enabled | Preference mutation succeeds with `enabled=true` | The launcher entry is checked, the eligible top-bar control appears, and the existing dialog opens. |
| Persisting disabled | User selects the checked launcher entry | The mutation is sent; existing tunnels are left untouched. |
| Unavailable | Session is missing or agentctl is not ready | The preference remains unchanged; the launcher entry is disabled and the top-bar control is hidden. |
| Mutation failed | The preference endpoint rejects or cannot persist the request | The prior enabled/disabled state remains authoritative, the dialog is not opened for a failed enable, and an error is shown. |
Dialog open/closed and tunnel inactive/active are independent runtime states. Closing the dialog does
not change the preference. Disabling the preference does not transition an active tunnel to stopped.
## Permissions
The new preference route uses the existing task visibility authorization: a caller must be allowed
to mutate the task's workspace. It introduces no new role or agent permission. Port discovery and
tunnel operations continue to use the existing session/task authorization of the port APIs and
proxy routes.
## Failure modes
- If the active session is not ready, the UI fails closed for runtime access: the top-bar control is
hidden and the launcher action is disabled rather than attempting a request against an unavailable
agentctl.
- If the preference write fails, the UI rolls back any optimistic presentation, keeps active tunnels
running, and shows the existing error feedback. It does not open a dialog that cannot be backed by
a ready session.
- If a `task.updated` event arrives without metadata, clients preserve their cached metadata. An
event that explicitly contains metadata is authoritative for the task preference.
- If port detection or tunnel start/stop fails, the existing Port Forwarding dialog reports the
runtime error without changing the saved visibility preference.
- If another client changes the preference, the task's `task.updated` event reconciles the checked
state; the server value wins over stale local optimistic state.
## Persistence guarantees
- The enabled/disabled preference survives page reloads, browser restarts, Kandev backend restarts,
task session replacement, and revisiting the same task. It is shared with other clients that can
see the task because it is task metadata.
- Active tunnels remain governed by their existing in-memory/session lifecycle. Disabling the UI
preference leaves them running, while a backend restart may still clear them as it does today.
- The preference is not copied to other tasks, inherited from an executor, or stored as a global user
setting.
## Scenarios
- **GIVEN** a local-executor task with a ready agentctl session and no preference, **WHEN** the user
opens the desktop task `+` menu, **THEN** **Port forwarding** is visible and unchecked while the
top-bar port-forwarding control is hidden.
- **GIVEN** the same local task, **WHEN** the user selects the unchecked entry, **THEN** the task
preference is enabled, the top-bar control appears, and the existing Port Forwarding dialog opens.
- **GIVEN** a remote-executor task with a ready agentctl session, **WHEN** the user enables the
entry, **THEN** it follows the same flow as the local task and does not use a separate remote-only
UI path.
- **GIVEN** an enabled task with an active tunnel, **WHEN** the user disables Port forwarding,
**THEN** the top-bar control disappears but the tunnel remains active and is still listed after the
preference is enabled again.
- **GIVEN** an enabled task, **WHEN** the user reloads the task or opens it in another client,
**THEN** the launcher entry remains checked and the ready top-bar control is visible.
- **GIVEN** a phone viewport, **WHEN** the user opens the task-switcher drawer and selects Port
forwarding, **THEN** the drawer closes, the same preference mutation occurs, the mobile top-bar
control appears, and the existing dialog opens without horizontal overflow.
- **GIVEN** a tablet viewport, **WHEN** the user opens the task-switcher sheet, **THEN** the same
active-task action is reachable with a touch-sized row and uses the same preference state.
- **GIVEN** an active task whose agentctl is not ready, **WHEN** the user opens the task launcher,
**THEN** Port forwarding is disabled with an accessible readiness explanation and no top-bar
control is rendered.
- **GIVEN** the preference endpoint fails, **WHEN** the user attempts to enable Port forwarding,
**THEN** the entry remains unchecked, the top-bar control remains hidden, and an error is shown.
- **GIVEN** a task with unrelated metadata, **WHEN** the user toggles Port forwarding, **THEN** all
unrelated metadata remains unchanged in the response and subsequent task reads.
## Out of scope
- Adding a new tunnel transport, proxy mode, port discovery mechanism, or host-wide tunnel manager.
- Persisting running tunnels or restoring them automatically after a backend restart.
- Stopping tunnels when the visibility preference is disabled.
- Adding a separate Dockview panel or a global settings toggle.
- Automatically exposing every detected port without the user's existing per-port tunnel action.