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