From de4e4161edc7270c8279c832361152cd53cf2793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 8 Aug 2026 13:49:58 +0100 Subject: [PATCH] feat: surface port forwarding controls and browser navigation (#2383) --- .../internal/task/handlers/task_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 28 +++ .../handlers/task_port_forwarding_test.go | 127 ++++++++++ apps/backend/internal/task/models/models.go | 3 + .../service/service_port_forwarding_test.go | 96 ++++++++ .../internal/task/service/service_workflow.go | 4 + apps/web/components/task/browser-panel.tsx | 7 +- .../task/dockview-add-panel-items.test.tsx | 44 ++++ .../task/dockview-add-panel-items.tsx | 18 ++ .../task/dockview-header-actions.tsx | 3 + .../components/task/dockview-watermark.tsx | 5 +- .../task/mobile/session-mobile-top-bar.tsx | 2 + .../mobile/session-task-switcher-sheet.tsx | 40 +++- .../task/port-forward-dialog-actions.tsx | 83 +++++++ .../task/port-forward-dialog.test.tsx | 155 +++++++++++++ .../components/task/port-forward-dialog.tsx | 136 +++++------ ...rt-forwarding-visibility-provider.test.tsx | 199 ++++++++++++++++ .../port-forwarding-visibility-provider.tsx | 158 +++++++++++++ .../task/port-forwarding-visibility.test.ts | 23 ++ .../task/port-forwarding-visibility.ts | 16 ++ .../task/task-page-content-helpers.test.ts | 21 ++ .../task/task-page-content-helpers.ts | 2 + apps/web/components/task/task-page-inner.tsx | 67 +++--- apps/web/components/task/task-top-bar.tsx | 22 +- apps/web/e2e/pages/session-page.ts | 54 +++++ .../session/mobile-port-forwarding.spec.ts | 47 ++++ .../tests/session/port-forward-dialog.spec.ts | 147 +++++++++++- apps/web/lib/api/domains/kanban-api.test.ts | 34 ++- apps/web/lib/api/domains/kanban-api.ts | 15 ++ apps/web/lib/kanban/map-task.test.ts | 16 +- apps/web/lib/kanban/map-task.ts | 1 + apps/web/lib/ssr/mapper.test.ts | 12 + apps/web/lib/ssr/mapper.ts | 1 + .../dockview-panel-actions.test-utils.ts | 2 + .../lib/state/dockview-panel-actions.test.ts | 113 +++++++++ apps/web/lib/state/dockview-panel-actions.ts | 27 ++- apps/web/lib/state/dockview-store.ts | 1 + apps/web/lib/state/slices/kanban/types.ts | 1 + .../ws/handlers/tasks-port-forwarding.test.ts | 109 +++++++++ apps/web/lib/ws/handlers/tasks.ts | 1 + apps/web/src/locales/en/task.json | 2 + apps/web/src/locales/pseudo/task.json | 2 + apps/web/src/locales/pt-pt/task.json | 2 + apps/web/src/locales/zh-cn/task.json | 1 + docs/plans/port-forwarding-discovery/plan.md | 216 ++++++++++++++++++ .../task-01-backend-preference-contract.md | 77 +++++++ .../task-02-frontend-preference-state.md | 100 ++++++++ .../task-03-task-surfaces.md | 88 +++++++ .../task-04-port-forwarding-coverage.md | 88 +++++++ docs/plans/port-proxy-browser-panel/plan.md | 162 +++++++++++++ .../task-01-dockview-browser-navigation.md | 45 ++++ .../task-02-port-dialog-browser-action.md | 49 ++++ .../task-03-browser-navigation-e2e.md | 42 ++++ docs/specs/INDEX.md | 2 + docs/specs/port-proxy-browser-panel/spec.md | 100 ++++++++ docs/specs/ui/port-forwarding-discovery.md | 161 +++++++++++++ 56 files changed, 2852 insertions(+), 126 deletions(-) create mode 100644 apps/backend/internal/task/handlers/task_port_forwarding_test.go create mode 100644 apps/backend/internal/task/service/service_port_forwarding_test.go create mode 100644 apps/web/components/task/port-forward-dialog-actions.tsx create mode 100644 apps/web/components/task/port-forward-dialog.test.tsx create mode 100644 apps/web/components/task/port-forwarding-visibility-provider.test.tsx create mode 100644 apps/web/components/task/port-forwarding-visibility-provider.tsx create mode 100644 apps/web/components/task/port-forwarding-visibility.test.ts create mode 100644 apps/web/components/task/port-forwarding-visibility.ts create mode 100644 apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts create mode 100644 apps/web/lib/ws/handlers/tasks-port-forwarding.test.ts create mode 100644 docs/plans/port-forwarding-discovery/plan.md create mode 100644 docs/plans/port-forwarding-discovery/task-01-backend-preference-contract.md create mode 100644 docs/plans/port-forwarding-discovery/task-02-frontend-preference-state.md create mode 100644 docs/plans/port-forwarding-discovery/task-03-task-surfaces.md create mode 100644 docs/plans/port-forwarding-discovery/task-04-port-forwarding-coverage.md create mode 100644 docs/plans/port-proxy-browser-panel/plan.md create mode 100644 docs/plans/port-proxy-browser-panel/task-01-dockview-browser-navigation.md create mode 100644 docs/plans/port-proxy-browser-panel/task-02-port-dialog-browser-action.md create mode 100644 docs/plans/port-proxy-browser-panel/task-03-browser-navigation-e2e.md create mode 100644 docs/specs/port-proxy-browser-panel/spec.md create mode 100644 docs/specs/ui/port-forwarding-discovery.md diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 2b21b4c7bd..86651417bb 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -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) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index c02c63ffcb..dd3ceed518 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -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 { diff --git a/apps/backend/internal/task/handlers/task_port_forwarding_test.go b/apps/backend/internal/task/handlers/task_port_forwarding_test.go new file mode 100644 index 0000000000..2629fdcb5e --- /dev/null +++ b/apps/backend/internal/task/handlers/task_port_forwarding_test.go @@ -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) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index cfd0adaff3..785d7bae2a 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -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 diff --git a/apps/backend/internal/task/service/service_port_forwarding_test.go b/apps/backend/internal/task/service/service_port_forwarding_test.go new file mode 100644 index 0000000000..0c3d4dc3ca --- /dev/null +++ b/apps/backend/internal/task/service/service_port_forwarding_test.go @@ -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) + } +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 5e72690b92..3bfb5839c7 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -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 } diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index 8f4b9add4c..64d5b32574 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -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 ( - + { }); }); +describe("AddPanelMenuItems — port forwarding preference", () => { + function portForwardingState( + overrides: Partial> = {}, + ): NonNullable { + 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(); diff --git a/apps/web/components/task/dockview-add-panel-items.tsx b/apps/web/components/task/dockview-add-panel-items.tsx index 67c52e369a..22ec16daae 100644 --- a/apps/web/components/task/dockview-add-panel-items.tsx +++ b/apps/web/components/task/dockview-add-panel-items.tsx @@ -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")} )} + {state.portForwarding && ( + + void state.portForwarding?.togglePortForwarding({ openDialogOnEnable: true }) + } + className={MENU_ITEM_CLASS} + data-testid="port-forwarding-menu-item" + > + + {t("task:portForwarding")} + + )} {!state.isPassthrough && ( addTodosPanel({ groupId })} className={MENU_ITEM_CLASS}> diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 335ea2a7fa..eae7bf16a1 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -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, }; } diff --git a/apps/web/components/task/dockview-watermark.tsx b/apps/web/components/task/dockview-watermark.tsx index 3dad900b6e..5795e54efc 100644 --- a/apps/web/components/task/dockview-watermark.tsx +++ b/apps/web/components/task/dockview-watermark.tsx @@ -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], ); } diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx index 1d376c04b1..13022c0c76 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -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 (
+ {!isArchived && } {!isArchived && ( + {data.activeTaskId && ( + onOpenChange(false)} + activeTaskId={data.activeTaskId} + /> + )}
@@ -390,6 +397,37 @@ function TaskSwitcherSurfaceContent({ ); } +function PortForwardingTaskAction({ + activeTaskId, + onClose, +}: { + activeTaskId: string; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { enabled, canToggle, isUpdating, togglePortForwarding } = usePortForwardingVisibility(); + + return ( +
+ +
+ ); +} + function TaskSwitcherDialogs({ dialogOpen, onDialogOpenChange, diff --git a/apps/web/components/task/port-forward-dialog-actions.tsx b/apps/web/components/task/port-forward-dialog-actions.tsx new file mode 100644 index 0000000000..24d19c13a3 --- /dev/null +++ b/apps/web/components/task/port-forward-dialog-actions.tsx @@ -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 ( +
+ + + + + {t("task:copyUrl")} + + + + + + {t("task:openInNewTab")} + + {onOpenBrowserPanel && ( + + + + + {t("task:openInBrowserPanel")} + + )} +
+ ); +} diff --git a/apps/web/components/task/port-forward-dialog.test.tsx b/apps/web/components/task/port-forward-dialog.test.tsx new file mode 100644 index 0000000000..6e0e20d5f0 --- /dev/null +++ b/apps/web/components/task/port-forward-dialog.test.tsx @@ -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 & { variant?: string }>) => ( + + ), +})); + +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ children }: PropsWithChildren) => <>{children}, + DialogContent: ({ children, ...props }: PropsWithChildren>) => ( +
{children}
+ ), + 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(), + handleTunnelStart: vi.fn(), + handleTunnelStop: vi.fn(), + }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + const secondSession = deferred(); + listTunnelsMock.mockImplementation((sessionId: string) => + sessionId === "session-1" ? firstSession.promise : secondSession.promise, + ); + + const { rerender } = render(); + rerender(); + + 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(); + + 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(); + expect(listTunnelsMock).not.toHaveBeenCalled(); + + visibilityMock.enabled = true; + rerender(); + + await waitFor(() => { + expect(listTunnelsMock).toHaveBeenCalledWith("session-1"); + expect(screen.getByTestId("port-forward-button").getAttribute("data-variant")).toBe( + "default", + ); + }); + }); +}); diff --git a/apps/web/components/task/port-forward-dialog.tsx b/apps/web/components/task/port-forward-dialog.tsx index d24a1f0e9b..09a7a7f788 100644 --- a/apps/web/components/task/port-forward-dialog.tsx +++ b/apps/web/components/task/port-forward-dialog.tsx @@ -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 ( -
- - - - - {t("task:copyUrl")} - - - - - - {t("task:openInNewTab")} - -
- ); -} - function TunnelToggleButton({ isTunnelActive, tunnelPending, @@ -111,7 +67,8 @@ function TunnelToggleButton({
); } @@ -291,6 +275,7 @@ function PortListSection({ pendingTunnels, onTunnelStart, onTunnelStop, + onOpenBrowserPanel, }: { detectedPorts: ListeningPort[]; manualPorts: number[]; @@ -302,6 +287,7 @@ function PortListSection({ pendingTunnels: Set; 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} /> ))} @@ -424,10 +412,12 @@ function PortForwardDialogContent({ sessionId, activeTunnels, setActiveTunnels, + onOpenBrowserPanel, }: { sessionId: string; activeTunnels: Map; setActiveTunnels: (updater: (prev: Map) => Map) => void; + onOpenBrowserPanel?: (url: string) => void; }) { const { t } = useTranslation(); const [detectedPorts, setDetectedPorts] = useState([]); @@ -480,7 +470,7 @@ function PortForwardDialogContent({ return ( !loaded && refresh()} > @@ -489,7 +479,7 @@ function PortForwardDialogContent({ {t("task:portForwarding")} -
+
@@ -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>(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) => Map) => { @@ -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 ( - + @@ -562,6 +565,7 @@ export function PortForwardButton({ sessionId={sessionId} activeTunnels={activeTunnels} setActiveTunnels={setActiveTunnels} + onOpenBrowserPanel={openBrowserPanel ? handleOpenBrowserPanel : undefined} /> ); diff --git a/apps/web/components/task/port-forwarding-visibility-provider.test.tsx b/apps/web/components/task/port-forwarding-visibility-provider.test.tsx new file mode 100644 index 0000000000..4413a71810 --- /dev/null +++ b/apps/web/components/task/port-forwarding-visibility-provider.test.tsx @@ -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 ( + + {children} + + ); +} + +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 }) => ( + + {children} + + ), + }); + + 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((resolve) => { + resolveToggle = resolve; + }), + ); + let providerProps = { + taskId: "task-1", + metadata: { port_forwarding_enabled: false } as Record, + }; + const dynamicWrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + 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((resolve) => { + resolveToggle = resolve; + }), + ); + const { result } = renderHook(() => usePortForwardingVisibility(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + 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) => ( + + {children} + + ); + 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); + }); +}); diff --git a/apps/web/components/task/port-forwarding-visibility-provider.tsx b/apps/web/components/task/port-forwarding-visibility-provider.tsx new file mode 100644 index 0000000000..d371624624 --- /dev/null +++ b/apps/web/components/task/port-forwarding-visibility-provider.tsx @@ -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; +}; + +type PortForwardingVisibilityProviderProps = { + taskId?: string | null; + metadata?: Task["metadata"]; + sessionId?: string | null; + isAgentctlReady: boolean; + isArchived?: boolean; + children: ReactNode; +}; + +const PortForwardingVisibilityContext = createContext(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(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( + () => ({ + enabled, + canToggle, + isUpdating, + dialogOpen, + setDialogOpen, + togglePortForwarding, + }), + [canToggle, dialogOpen, enabled, isUpdating, togglePortForwarding], + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/components/task/port-forwarding-visibility.test.ts b/apps/web/components/task/port-forwarding-visibility.test.ts new file mode 100644 index 0000000000..6865854cf2 --- /dev/null +++ b/apps/web/components/task/port-forwarding-visibility.test.ts @@ -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); + }); +}); diff --git a/apps/web/components/task/port-forwarding-visibility.ts b/apps/web/components/task/port-forwarding-visibility.ts new file mode 100644 index 0000000000..78dfff409b --- /dev/null +++ b/apps/web/components/task/port-forwarding-visibility.ts @@ -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 | 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); +} diff --git a/apps/web/components/task/task-page-content-helpers.test.ts b/apps/web/components/task/task-page-content-helpers.test.ts index 370a94a550..03a87d08a4 100644 --- a/apps/web/components/task/task-page-content-helpers.test.ts +++ b/apps/web/components/task/task-page-content-helpers.test.ts @@ -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" }); diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index f355e72dd6..6754b70cff 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -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, }; } diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index 10b0164bba..f75462d1a8 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -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; 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 ( - -
- - - - {!isMobile && } - {ensureSession.status === "error" && ( - +
+ - )} - - - -
- + + + {!isMobile && } + {ensureSession.status === "error" && ( + + )} + + + +
+
+
); } diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 2b36830dd9..de5b199905 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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({ {!isArchived && ( <> - + {/* 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} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 5fe8840359..978b695255 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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 { + 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 { + 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 { + 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"); diff --git a/apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts b/apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts new file mode 100644 index 0000000000..8bee18eb3f --- /dev/null +++ b/apps/web/e2e/tests/session/mobile-port-forwarding.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/e2e/tests/session/port-forward-dialog.spec.ts b/apps/web/e2e/tests/session/port-forward-dialog.spec.ts index cf71871b10..77d3795e11 100644 --- a/apps/web/e2e/tests/session/port-forward-dialog.spec.ts +++ b/apps/web/e2e/tests/session/port-forward-dialog.spec.ts @@ -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(); diff --git a/apps/web/lib/api/domains/kanban-api.test.ts b/apps/web/lib/api/domains/kanban-api.test.ts index 11e070d4ea..38fe13f3e6 100644 --- a/apps/web/lib/api/domains/kanban-api.test.ts +++ b/apps/web/lib/api/domains/kanban-api.test.ts @@ -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(); 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( diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index dac4da74ee..ee2f60f24b 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -139,6 +139,21 @@ export async function updateTask( }); } +export async function updateTaskPortForwarding( + taskId: string, + enabled: boolean, + options?: ApiRequestOptions, +) { + return fetchJson(`/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(`/api/v1/tasks/${taskId}/detach`, { ...options, diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index c7845fd30e..ea1f602238 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -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); } }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 356556d143..0e7b5f2f27 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -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), diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index 65ed1a5669..d84eb002e0 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -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], diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 68b4bd7a43..66c91065f5 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -53,6 +53,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { 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), diff --git a/apps/web/lib/state/dockview-panel-actions.test-utils.ts b/apps/web/lib/state/dockview-panel-actions.test-utils.ts index 9fbe02e737..3a823403b0 100644 --- a/apps/web/lib/state/dockview-panel-actions.test-utils.ts +++ b/apps/web/lib/state/dockview-panel-actions.test-utils.ts @@ -9,6 +9,7 @@ export type MockPanel = { group: { id: string }; isActive: boolean; api: { + component: string; setActive: () => void; updateParameters: (p: Record) => 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; diff --git a/apps/web/lib/state/dockview-panel-actions.test.ts b/apps/web/lib/state/dockview-panel-actions.test.ts index a09a37894f..5472475b68 100644 --- a/apps/web/lib/state/dockview-panel-actions.test.ts +++ b/apps/web/lib/state/dockview-panel-actions.test.ts @@ -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; + + 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; diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts index 052a02db99..7bdca38312 100644 --- a/apps/web/lib/state/dockview-panel-actions.ts +++ b/apps/web/lib/state/dockview-panel-actions.ts @@ -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; diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 1fffba6c3d..1221baf8f2 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -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; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 5761ae71c2..5e7b47a824 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -101,6 +101,7 @@ export type KanbanState = { queuedAt?: string; isPRReview?: boolean; isIssueWatch?: boolean; + metadata?: Record | null; isArchived?: boolean; issueUrl?: string; issueNumber?: number; diff --git a/apps/web/lib/ws/handlers/tasks-port-forwarding.test.ts b/apps/web/lib/ws/handlers/tasks-port-forwarding.test.ts new file mode 100644 index 0000000000..6d6f748f0b --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-port-forwarding.test.ts @@ -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 & { getState: () => AppState }; +} + +function taskUpdated(payload: Record) { + return { + id: "message-1", + type: "notification" as const, + action: "task.updated" as const, + payload, + } as Parameters["task.updated"]>>[0]; +} + +function taskPayload(metadata: Record | 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(); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 75502494db..649d93be14 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -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 diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 3f691253fa..69f45e5eaf 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -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", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 6739975121..5b6d1feb23 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -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": "Ƥōŕţ ńũḿƀēŕ", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 37656cd933..69aaf64f93 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -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", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 346ae490a2..550038235c 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -807,6 +807,7 @@ "openFile": "打开文件", "openImage": "打开 {{alt}}", "openInAdvancedView": "在高级视图中打开", + "openInBrowserPanel": "在浏览器面板中打开", "openInBrowserTab": "在浏览器标签页中打开", "openInEditorNamed": "在 {{name}} 中打开", "openInNewTab": "在新标签页中打开", diff --git a/docs/plans/port-forwarding-discovery/plan.md b/docs/plans/port-forwarding-discovery/plan.md new file mode 100644 index 0000000000..329bfd9a82 --- /dev/null +++ b/docs/plans/port-forwarding-discovery/plan.md @@ -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. diff --git a/docs/plans/port-forwarding-discovery/task-01-backend-preference-contract.md b/docs/plans/port-forwarding-discovery/task-01-backend-preference-contract.md new file mode 100644 index 0000000000..e3e54308d8 --- /dev/null +++ b/docs/plans/port-forwarding-discovery/task-01-backend-preference-contract.md @@ -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. diff --git a/docs/plans/port-forwarding-discovery/task-02-frontend-preference-state.md b/docs/plans/port-forwarding-discovery/task-02-frontend-preference-state.md new file mode 100644 index 0000000000..27ebaee0c3 --- /dev/null +++ b/docs/plans/port-forwarding-discovery/task-02-frontend-preference-state.md @@ -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. diff --git a/docs/plans/port-forwarding-discovery/task-03-task-surfaces.md b/docs/plans/port-forwarding-discovery/task-03-task-surfaces.md new file mode 100644 index 0000000000..f404bef420 --- /dev/null +++ b/docs/plans/port-forwarding-discovery/task-03-task-surfaces.md @@ -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. diff --git a/docs/plans/port-forwarding-discovery/task-04-port-forwarding-coverage.md b/docs/plans/port-forwarding-discovery/task-04-port-forwarding-coverage.md new file mode 100644 index 0000000000..8a34784de7 --- /dev/null +++ b/docs/plans/port-forwarding-discovery/task-04-port-forwarding-coverage.md @@ -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. diff --git a/docs/plans/port-proxy-browser-panel/plan.md b/docs/plans/port-proxy-browser-panel/plan.md new file mode 100644 index 0000000000..a9141e1b58 --- /dev/null +++ b/docs/plans/port-proxy-browser-panel/plan.md @@ -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:` 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. diff --git a/docs/plans/port-proxy-browser-panel/task-01-dockview-browser-navigation.md b/docs/plans/port-proxy-browser-panel/task-01-dockview-browser-navigation.md new file mode 100644 index 0000000000..eead518958 --- /dev/null +++ b/docs/plans/port-proxy-browser-panel/task-01-dockview-browser-navigation.md @@ -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. diff --git a/docs/plans/port-proxy-browser-panel/task-02-port-dialog-browser-action.md b/docs/plans/port-proxy-browser-panel/task-02-port-dialog-browser-action.md new file mode 100644 index 0000000000..81429f04ce --- /dev/null +++ b/docs/plans/port-proxy-browser-panel/task-02-port-dialog-browser-action.md @@ -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. diff --git a/docs/plans/port-proxy-browser-panel/task-03-browser-navigation-e2e.md b/docs/plans/port-proxy-browser-panel/task-03-browser-navigation-e2e.md new file mode 100644 index 0000000000..59d717ca71 --- /dev/null +++ b/docs/plans/port-proxy-browser-panel/task-03-browser-navigation-e2e.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 0a08834fe8..cc05b35e6e 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/port-proxy-browser-panel/spec.md b/docs/specs/port-proxy-browser-panel/spec.md new file mode 100644 index 0000000000..4066019aa1 --- /dev/null +++ b/docs/specs/port-proxy-browser-panel/spec.md @@ -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. diff --git a/docs/specs/ui/port-forwarding-discovery.md b/docs/specs/ui/port-forwarding-discovery.md new file mode 100644 index 0000000000..72966ae68a --- /dev/null +++ b/docs/specs/ui/port-forwarding-discovery.md @@ -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.