feat(editors): scope embedded VS Code by executor (#2059)
This commit is contained in:
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -708,7 +707,6 @@ func webRuntimeConfig(debug bool) webapp.RuntimeConfig {
|
||||
return webapp.RuntimeConfig{
|
||||
APIPrefix: "/api/v1",
|
||||
WebSocketPath: "/ws",
|
||||
HostOS: runtime.GOOS,
|
||||
Debug: debug,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1153,27 +1152,6 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadIncludesBackendHostOS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := bootPayload(context.Background(), nil, routeParams{}, webapp.ClassifyRoute("/"))
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal payload: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Runtime struct {
|
||||
HostOS string `json:"hostOS"`
|
||||
} `json:"runtime"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal payload: %v", err)
|
||||
}
|
||||
if decoded.Runtime.HostOS != runtime.GOOS {
|
||||
t.Fatalf("runtime.hostOS = %q, want %q", decoded.Runtime.HostOS, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package capabilities resolves editor support from a task session's executor.
|
||||
package capabilities
|
||||
|
||||
import "github.com/kandev/kandev/internal/task/models"
|
||||
|
||||
const (
|
||||
hostOSLinux = "linux"
|
||||
hostOSDarwin = "darwin"
|
||||
)
|
||||
|
||||
// SupportsEmbeddedVscode reports whether the executor can run Kandev's
|
||||
// code-server-based embedded editor. Unknown and test-only executors fail
|
||||
// closed so callers never advertise an unsupported launch path.
|
||||
func SupportsEmbeddedVscode(executorType models.ExecutorType, hostOS string) bool {
|
||||
switch executorType {
|
||||
case models.ExecutorTypeLocal, models.ExecutorTypeWorktree:
|
||||
return hostOS == hostOSLinux || hostOS == hostOSDarwin
|
||||
case models.ExecutorTypeLocalDocker, models.ExecutorTypeRemoteDocker,
|
||||
models.ExecutorTypeSprites, models.ExecutorTypeSSH:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
func TestSupportsEmbeddedVscode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
executorType models.ExecutorType
|
||||
hostOS string
|
||||
want bool
|
||||
}{
|
||||
{name: "local linux", executorType: models.ExecutorTypeLocal, hostOS: "linux", want: true},
|
||||
{name: "local macOS", executorType: models.ExecutorTypeLocal, hostOS: "darwin", want: true},
|
||||
{name: "local windows", executorType: models.ExecutorTypeLocal, hostOS: "windows", want: false},
|
||||
{name: "local unknown host", executorType: models.ExecutorTypeLocal, hostOS: "freebsd", want: false},
|
||||
{name: "worktree linux", executorType: models.ExecutorTypeWorktree, hostOS: "linux", want: true},
|
||||
{name: "worktree macOS", executorType: models.ExecutorTypeWorktree, hostOS: "darwin", want: true},
|
||||
{name: "worktree windows", executorType: models.ExecutorTypeWorktree, hostOS: "windows", want: false},
|
||||
{name: "worktree unknown host", executorType: models.ExecutorTypeWorktree, hostOS: "freebsd", want: false},
|
||||
{name: "local Docker on Windows host", executorType: models.ExecutorTypeLocalDocker, hostOS: "windows", want: true},
|
||||
{name: "remote Docker", executorType: models.ExecutorTypeRemoteDocker, hostOS: "windows", want: true},
|
||||
{name: "Sprites", executorType: models.ExecutorTypeSprites, hostOS: "windows", want: true},
|
||||
{name: "SSH", executorType: models.ExecutorTypeSSH, hostOS: "windows", want: true},
|
||||
{name: "mock remote fails closed", executorType: models.ExecutorTypeMockRemote, hostOS: "linux", want: false},
|
||||
{name: "unknown executor fails closed", executorType: models.ExecutorType("future"), hostOS: "linux", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SupportsEmbeddedVscode(tt.executorType, tt.hostOS); got != tt.want {
|
||||
t.Errorf("SupportsEmbeddedVscode(%q, %q) = %v, want %v", tt.executorType, tt.hostOS, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kandev/kandev/internal/common/securityutil"
|
||||
"github.com/kandev/kandev/internal/editors/capabilities"
|
||||
"github.com/kandev/kandev/internal/editors/models"
|
||||
"github.com/kandev/kandev/internal/editors/store"
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
type taskSessionReader interface {
|
||||
GetTaskSession(ctx context.Context, id string) (*taskmodels.TaskSession, error)
|
||||
GetRepository(ctx context.Context, id string) (*taskmodels.Repository, error)
|
||||
GetExecutor(ctx context.Context, id string) (*taskmodels.Executor, error)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -99,6 +101,9 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if editor.Kind == editorKindInternalVscode && !s.supportsEmbeddedVscode(ctx, session) {
|
||||
return "", ErrEditorUnavailable
|
||||
}
|
||||
// Opening the embedded editor at folder level only needs a valid session.
|
||||
// Repository-less sessions still have an executor workspace where code-server
|
||||
// can run, but they intentionally have no task worktree to resolve here.
|
||||
@@ -119,6 +124,17 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string
|
||||
return s.dispatchEditorKind(editor, worktreePath, absPath, input.Line, input.Column)
|
||||
}
|
||||
|
||||
func (s *Service) supportsEmbeddedVscode(ctx context.Context, session *taskmodels.TaskSession) bool {
|
||||
if session == nil || session.ExecutorID == "" {
|
||||
return false
|
||||
}
|
||||
executor, err := s.taskRepo.GetExecutor(ctx, session.ExecutorID)
|
||||
if err != nil || executor == nil {
|
||||
return false
|
||||
}
|
||||
return capabilities.SupportsEmbeddedVscode(executor.Type, runtime.GOOS)
|
||||
}
|
||||
|
||||
func (s *Service) dispatchEditorKind(editor *models.Editor, worktreePath, absPath string, line, column int) (string, error) {
|
||||
switch editor.Kind {
|
||||
case editorKindInternalVscode:
|
||||
|
||||
@@ -23,7 +23,8 @@ func (r *openEditorRepository) GetEditorByID(_ context.Context, editorID string)
|
||||
}
|
||||
|
||||
type openEditorTaskRepository struct {
|
||||
session *taskmodels.TaskSession
|
||||
session *taskmodels.TaskSession
|
||||
executor *taskmodels.Executor
|
||||
}
|
||||
|
||||
func (r *openEditorTaskRepository) GetTaskSession(_ context.Context, _ string) (*taskmodels.TaskSession, error) {
|
||||
@@ -34,6 +35,10 @@ func (r *openEditorTaskRepository) GetRepository(_ context.Context, _ string) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *openEditorTaskRepository) GetExecutor(_ context.Context, _ string) (*taskmodels.Executor, error) {
|
||||
return r.executor, nil
|
||||
}
|
||||
|
||||
type openEditorUserSettings struct {
|
||||
defaultEditorID string
|
||||
}
|
||||
@@ -54,7 +59,10 @@ func TestOpenEditor_InternalVscodeWithoutWorkspace(t *testing.T) {
|
||||
Kind: editorKindInternalVscode,
|
||||
Enabled: true,
|
||||
}},
|
||||
&openEditorTaskRepository{session: &taskmodels.TaskSession{ID: "session-1"}},
|
||||
&openEditorTaskRepository{
|
||||
session: &taskmodels.TaskSession{ID: "session-1", ExecutorID: "executor-1"},
|
||||
executor: &taskmodels.Executor{ID: "executor-1", Type: taskmodels.ExecutorTypeLocalDocker},
|
||||
},
|
||||
&openEditorUserSettings{defaultEditorID: editorID},
|
||||
)
|
||||
|
||||
@@ -91,6 +99,32 @@ func TestOpenEditor_InternalVscodeRejectsMissingSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEditor_InternalVscodeRejectsUnsupportedExecutor(t *testing.T) {
|
||||
const editorID = "embedded-vscode"
|
||||
svc := NewService(
|
||||
&openEditorRepository{editor: &editormodels.Editor{
|
||||
ID: editorID,
|
||||
Kind: editorKindInternalVscode,
|
||||
Enabled: true,
|
||||
}},
|
||||
&openEditorTaskRepository{
|
||||
session: &taskmodels.TaskSession{ID: "session-1", ExecutorID: "executor-1"},
|
||||
executor: &taskmodels.Executor{ID: "executor-1", Type: taskmodels.ExecutorTypeMockRemote},
|
||||
},
|
||||
&openEditorUserSettings{defaultEditorID: editorID},
|
||||
)
|
||||
|
||||
for _, input := range []OpenEditorInput{
|
||||
{SessionID: "session-1"},
|
||||
{SessionID: "session-1", EditorID: editorID, FilePath: "main.go"},
|
||||
} {
|
||||
_, err := svc.OpenEditor(context.Background(), input)
|
||||
if err != ErrEditorUnavailable {
|
||||
t.Fatalf("OpenEditor(%+v) error = %v, want ErrEditorUnavailable", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFolder_EmptySessionID(t *testing.T) {
|
||||
svc := &Service{}
|
||||
err := svc.OpenFolder(context.Background(), "", "")
|
||||
|
||||
@@ -29,6 +29,13 @@ type QueueResponse struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// TaskSessionCapabilities describes the launch features supported by the
|
||||
// current session executor. It is backend-owned so clients never infer runtime
|
||||
// support from an executor name or browser platform.
|
||||
type TaskSessionCapabilities struct {
|
||||
EmbeddedVscode bool `json:"embedded_vscode"`
|
||||
}
|
||||
|
||||
// TaskSessionStatusResponse is the response for task.session.status
|
||||
type TaskSessionStatusResponse struct {
|
||||
// Session metadata
|
||||
@@ -49,16 +56,17 @@ type TaskSessionStatusResponse struct {
|
||||
ACPSessionID string `json:"acp_session_id,omitempty"`
|
||||
|
||||
// Executor/runtime info
|
||||
ExecutorID string `json:"executor_id,omitempty"`
|
||||
ExecutorType string `json:"executor_type,omitempty"`
|
||||
ExecutorName string `json:"executor_name,omitempty"`
|
||||
Runtime agentruntime.Runtime `json:"runtime,omitempty"`
|
||||
IsRemoteExecutor bool `json:"is_remote_executor"`
|
||||
RemoteState string `json:"remote_state,omitempty"`
|
||||
RemoteName string `json:"remote_name,omitempty"`
|
||||
RemoteCreatedAt string `json:"remote_created_at,omitempty"`
|
||||
RemoteCheckedAt string `json:"remote_checked_at,omitempty"`
|
||||
RemoteStatusErr string `json:"remote_status_error,omitempty"`
|
||||
ExecutorID string `json:"executor_id,omitempty"`
|
||||
ExecutorType string `json:"executor_type,omitempty"`
|
||||
ExecutorName string `json:"executor_name,omitempty"`
|
||||
Runtime agentruntime.Runtime `json:"runtime,omitempty"`
|
||||
IsRemoteExecutor bool `json:"is_remote_executor"`
|
||||
Capabilities TaskSessionCapabilities `json:"capabilities"`
|
||||
RemoteState string `json:"remote_state,omitempty"`
|
||||
RemoteName string `json:"remote_name,omitempty"`
|
||||
RemoteCreatedAt string `json:"remote_created_at,omitempty"`
|
||||
RemoteCheckedAt string `json:"remote_checked_at,omitempty"`
|
||||
RemoteStatusErr string `json:"remote_status_error,omitempty"`
|
||||
|
||||
// Worktree info
|
||||
WorktreePath *string `json:"worktree_path,omitempty"`
|
||||
|
||||
@@ -870,6 +870,9 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if execCfg.ExecutorID != "" {
|
||||
session.ExecutorID = execCfg.ExecutorID
|
||||
}
|
||||
req.OfficeAgentProfileID = opts.OfficeAgentProfileID
|
||||
if req.OfficeAgentProfileID == "" && session.AgentProfileID != "" {
|
||||
req.OfficeAgentProfileID = session.AgentProfileID
|
||||
|
||||
@@ -415,6 +415,50 @@ func TestLaunchPreparedSession_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_PersistsResolvedExecutorID(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
now := time.Now().UTC()
|
||||
repo.sessions["session-123"] = &models.TaskSession{
|
||||
ID: "session-123",
|
||||
TaskID: "task-123",
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDLocal,
|
||||
State: models.TaskSessionStateCreated,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
repo.executors[models.ExecutorIDLocalDocker] = &models.Executor{
|
||||
ID: models.ExecutorIDLocalDocker,
|
||||
Type: models.ExecutorTypeLocalDocker,
|
||||
Status: models.ExecutorStatusActive,
|
||||
}
|
||||
|
||||
executor := newTestExecutor(t, &mockAgentManager{
|
||||
launchAgentFunc: func(context.Context, *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
return &LaunchAgentResponse{AgentExecutionID: "exec-123", Status: v1.AgentStatusStarting}, nil
|
||||
},
|
||||
}, repo)
|
||||
|
||||
_, err := executor.LaunchPreparedSession(context.Background(), &v1.Task{
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-123",
|
||||
}, "session-123", LaunchOptions{
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDLocalDocker,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchPreparedSession failed: %v", err)
|
||||
}
|
||||
|
||||
stored, err := repo.GetTaskSession(context.Background(), "session-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession failed: %v", err)
|
||||
}
|
||||
if stored.ExecutorID != models.ExecutorIDLocalDocker {
|
||||
t.Fatalf("ExecutorID = %q, want %q", stored.ExecutorID, models.ExecutorIDLocalDocker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_AbortsWhenStartingPersistenceFails(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
session := &models.TaskSession{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
|
||||
"github.com/kandev/kandev/internal/common/constants"
|
||||
"github.com/kandev/kandev/internal/editors/capabilities"
|
||||
"github.com/kandev/kandev/internal/orchestrator/dto"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
@@ -2161,6 +2163,7 @@ func (s *Service) populateExecutorStatusInfo(ctx context.Context, session *model
|
||||
resp.ExecutorType = string(execModel.Type)
|
||||
resp.ExecutorName = execModel.Name
|
||||
resp.IsRemoteExecutor = models.IsRemoteExecutorType(execModel.Type)
|
||||
resp.Capabilities.EmbeddedVscode = capabilities.SupportsEmbeddedVscode(execModel.Type, runtime.GOOS)
|
||||
}
|
||||
|
||||
func (s *Service) applyRemoteRuntimeStatus(ctx context.Context, sessionID string, resp *dto.TaskSessionStatusResponse) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -4578,6 +4579,64 @@ func TestErrorClassificationFunctions(t *testing.T) {
|
||||
|
||||
// --- GetTaskSessionStatus ---
|
||||
|
||||
func TestGetTaskSessionStatus_ReportsEmbeddedVscodeCapability(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
|
||||
|
||||
if err := repo.CreateExecutor(ctx, &models.Executor{
|
||||
ID: "executor-docker",
|
||||
Name: "Docker",
|
||||
Type: models.ExecutorTypeLocalDocker,
|
||||
Status: models.ExecutorStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("create executor: %v", err)
|
||||
}
|
||||
session, err := repo.GetTaskSession(ctx, "session1")
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
session.ExecutorID = "executor-docker"
|
||||
if err := repo.UpdateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("update session: %v", err)
|
||||
}
|
||||
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
|
||||
svc.executor = executor.NewExecutor(svc.agentManager, repo, testLogger(), executor.ExecutorConfig{})
|
||||
|
||||
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSessionStatus: %v", err)
|
||||
}
|
||||
if !resp.Capabilities.EmbeddedVscode {
|
||||
t.Fatal("embedded_vscode = false, want true for a Docker session")
|
||||
}
|
||||
encoded, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal status response: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"capabilities":{"embedded_vscode":true}`) {
|
||||
t.Fatalf("serialized status = %s, want embedded_vscode capability", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaskSessionStatus_FailsClosedWithoutExecutorCapability(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
|
||||
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
|
||||
svc.executor = executor.NewExecutor(svc.agentManager, repo, testLogger(), executor.ExecutorConfig{})
|
||||
|
||||
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSessionStatus: %v", err)
|
||||
}
|
||||
if resp.Capabilities.EmbeddedVscode {
|
||||
t.Fatal("embedded_vscode = true, want false without an executor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaskSessionStatus_HealsStuckStartingSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
|
||||
@@ -36,7 +36,6 @@ type ActivePluginPayload struct {
|
||||
type RuntimeConfig struct {
|
||||
APIPrefix string `json:"apiPrefix"`
|
||||
WebSocketPath string `json:"webSocketPath"`
|
||||
HostOS string `json:"hostOS"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -24,18 +24,20 @@ describe("getAvailableTaskTopbarEditors", () => {
|
||||
editor({ id: "missing", kind: "built_in", installed: false }),
|
||||
];
|
||||
|
||||
it("hides embedded VS Code when the backend host is Windows", () => {
|
||||
expect(getAvailableTaskTopbarEditors(editors, "windows").map(({ id }) => id)).toEqual(["zed"]);
|
||||
});
|
||||
|
||||
it("keeps embedded VS Code for non-Windows or unknown hosts", () => {
|
||||
for (const hostOS of ["linux", "darwin", undefined]) {
|
||||
expect(getAvailableTaskTopbarEditors(editors, hostOS).map(({ id }) => id)).toEqual([
|
||||
"internal-vscode",
|
||||
it("hides embedded VS Code when the active session does not support it", () => {
|
||||
for (const supported of [false, undefined]) {
|
||||
expect(getAvailableTaskTopbarEditors(editors, supported).map(({ id }) => id)).toEqual([
|
||||
"zed",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps embedded VS Code when the active session supports it", () => {
|
||||
expect(getAvailableTaskTopbarEditors(editors, true).map(({ id }) => id)).toEqual([
|
||||
"internal-vscode",
|
||||
"zed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskTopbarEditorId", () => {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
|
||||
const WINDOWS_HOST_OS = "windows";
|
||||
const EMBEDDED_VSCODE_KIND = "internal_vscode";
|
||||
|
||||
export function getAvailableTaskTopbarEditors(
|
||||
editors: EditorOption[],
|
||||
hostOS?: string,
|
||||
embeddedVscodeSupported = false,
|
||||
): EditorOption[] {
|
||||
return editors.filter((editor) => {
|
||||
if (!editor.enabled) return false;
|
||||
if (editor.kind === "built_in" && !editor.installed) return false;
|
||||
return !(hostOS === WINDOWS_HOST_OS && editor.kind === EMBEDDED_VSCODE_KIND);
|
||||
return editor.kind !== EMBEDDED_VSCODE_KIND || embeddedVscodeSupported;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor";
|
||||
import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
import { readBackendHostOS } from "@/src/boot-payload";
|
||||
import {
|
||||
buildWorktreeOptions,
|
||||
type WorktreeOption,
|
||||
@@ -32,6 +31,7 @@ const menuItemClass = "cursor-pointer";
|
||||
|
||||
type EditorsMenuProps = {
|
||||
activeSessionId: string | null;
|
||||
embeddedVscodeSupported: boolean;
|
||||
};
|
||||
|
||||
function useWorktreeOptions(sessionId: string | null): WorktreeOption[] {
|
||||
@@ -172,16 +172,14 @@ function EditorMenuEntry({
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorsMenu({ activeSessionId }: EditorsMenuProps) {
|
||||
export function EditorsMenu({ activeSessionId, embeddedVscodeSupported }: EditorsMenuProps) {
|
||||
const openEditor = useOpenSessionInEditor(activeSessionId ?? null);
|
||||
const { editors } = useEditors();
|
||||
const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId);
|
||||
const worktreeOptions = useWorktreeOptions(activeSessionId ?? null);
|
||||
const backendHostOS = readBackendHostOS();
|
||||
|
||||
const enabledEditors = useMemo(
|
||||
() => getAvailableTaskTopbarEditors(editors, backendHostOS),
|
||||
[editors, backendHostOS],
|
||||
() => getAvailableTaskTopbarEditors(editors, embeddedVscodeSupported),
|
||||
[editors, embeddedVscodeSupported],
|
||||
);
|
||||
|
||||
const resolvedEditorId = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEmbeddedVscodeSupported } from "./task-page-editor-capability";
|
||||
|
||||
describe("getEmbeddedVscodeSupported", () => {
|
||||
it("fails closed while session capability data is missing", () => {
|
||||
expect(getEmbeddedVscodeSupported(null)).toBe(false);
|
||||
expect(getEmbeddedVscodeSupported({})).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the active session capability only when it is explicitly true", () => {
|
||||
expect(getEmbeddedVscodeSupported({ capabilities: { embedded_vscode: true } })).toBe(true);
|
||||
expect(getEmbeddedVscodeSupported({ capabilities: { embedded_vscode: false } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
type SessionEditorCapabilityStatus = {
|
||||
capabilities?: {
|
||||
embedded_vscode?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function getEmbeddedVscodeSupported(
|
||||
status: SessionEditorCapabilityStatus | null | undefined,
|
||||
): boolean {
|
||||
return status?.capabilities?.embedded_vscode === true;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { Layout } from "react-resizable-panels";
|
||||
import { TaskArchivedProvider } from "./task-archived-context";
|
||||
import { SessionCommands } from "@/components/session-commands";
|
||||
import { TaskPRShortcut } from "@/components/task/task-pr-shortcut";
|
||||
import { getEmbeddedVscodeSupported } from "@/components/task/task-page-editor-capability";
|
||||
import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs";
|
||||
import {
|
||||
buildDebugEntries,
|
||||
@@ -62,6 +63,9 @@ type RemoteExecutorStatus = {
|
||||
remote_created_at?: string | null;
|
||||
remote_checked_at?: string | null;
|
||||
remote_status_error?: string | null;
|
||||
capabilities?: {
|
||||
embedded_vscode?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function toNullable(value: string | null | undefined): string | null {
|
||||
@@ -100,6 +104,7 @@ function buildTaskTopBarProps(params: {
|
||||
remote: ReturnType<typeof resolveRemoteExecutor>;
|
||||
sessionWorkflowStepId: string | null;
|
||||
agentctlReady: boolean;
|
||||
embeddedVscodeSupported: boolean;
|
||||
officeTaskHref?: string | null;
|
||||
onTaskUnarchived: (taskId: string) => void;
|
||||
}) {
|
||||
@@ -124,6 +129,7 @@ function buildTaskTopBarProps(params: {
|
||||
isArchived: taskProps.isArchived,
|
||||
isRemoteExecutor: params.remote.isRemoteExecutor,
|
||||
isAgentctlReady: params.agentctlReady,
|
||||
embeddedVscodeSupported: params.embeddedVscodeSupported,
|
||||
remoteExecutorType: params.remote.remoteExecutorType,
|
||||
officeTaskHref: params.officeTaskHref,
|
||||
onTaskUnarchived: params.onTaskUnarchived,
|
||||
@@ -220,6 +226,7 @@ export function TaskPageInner({
|
||||
}: TaskPageInnerProps) {
|
||||
const taskProps = resolveTaskProps(task, repository);
|
||||
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
|
||||
const embeddedVscodeSupported = getEmbeddedVscodeSupported(resumption.sessionStatus);
|
||||
const activeSessionMetadata = useAppStore((state) =>
|
||||
effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.metadata ?? null) : null,
|
||||
);
|
||||
@@ -245,6 +252,7 @@ export function TaskPageInner({
|
||||
remote,
|
||||
sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
|
||||
agentctlReady: agentctlStatus.isReady,
|
||||
embeddedVscodeSupported,
|
||||
officeTaskHref,
|
||||
onTaskUnarchived,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ type TaskTopBarProps = {
|
||||
isArchived?: boolean;
|
||||
isRemoteExecutor?: boolean;
|
||||
isAgentctlReady?: boolean;
|
||||
embeddedVscodeSupported?: boolean;
|
||||
remoteExecutorType?: string | null;
|
||||
officeTaskHref?: string | null;
|
||||
onTaskUnarchived?: (taskId: string) => void;
|
||||
@@ -68,6 +69,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
isArchived,
|
||||
isRemoteExecutor,
|
||||
isAgentctlReady,
|
||||
embeddedVscodeSupported,
|
||||
issueUrl,
|
||||
issueNumber,
|
||||
remoteExecutorType,
|
||||
@@ -106,6 +108,7 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
workspaceId={workspaceId}
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
isAgentctlReady={isAgentctlReady}
|
||||
embeddedVscodeSupported={embeddedVscodeSupported}
|
||||
taskTitle={taskTitle}
|
||||
issueUrl={issueUrl}
|
||||
issueNumber={issueNumber}
|
||||
@@ -298,11 +301,13 @@ function TopbarToolsGroup({
|
||||
showDebugOverlay,
|
||||
onToggleDebugOverlay,
|
||||
isArchived,
|
||||
embeddedVscodeSupported,
|
||||
}: {
|
||||
activeSessionId?: string | null;
|
||||
showDebugOverlay?: boolean;
|
||||
onToggleDebugOverlay?: () => void;
|
||||
isArchived?: boolean;
|
||||
embeddedVscodeSupported?: boolean;
|
||||
}) {
|
||||
const showDebugToggle = isDebugUI() && onToggleDebugOverlay;
|
||||
|
||||
@@ -311,7 +316,10 @@ function TopbarToolsGroup({
|
||||
{!isArchived && (
|
||||
<>
|
||||
<LayoutPresetSelector />
|
||||
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
||||
<EditorsMenu
|
||||
activeSessionId={activeSessionId ?? null}
|
||||
embeddedVscodeSupported={embeddedVscodeSupported ?? false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showDebugToggle && (
|
||||
@@ -336,6 +344,7 @@ function TopBarRight({
|
||||
workspaceId,
|
||||
isRemoteExecutor,
|
||||
isAgentctlReady,
|
||||
embeddedVscodeSupported,
|
||||
taskTitle,
|
||||
issueUrl,
|
||||
issueNumber,
|
||||
@@ -350,6 +359,7 @@ function TopBarRight({
|
||||
workspaceId?: string | null;
|
||||
isRemoteExecutor?: boolean;
|
||||
isAgentctlReady?: boolean;
|
||||
embeddedVscodeSupported?: boolean;
|
||||
taskTitle?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
@@ -396,6 +406,7 @@ function TopBarRight({
|
||||
showDebugOverlay={showDebugOverlay}
|
||||
onToggleDebugOverlay={onToggleDebugOverlay}
|
||||
isArchived={isArchived}
|
||||
embeddedVscodeSupported={embeddedVscodeSupported}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function rewriteBackendHostOS(page: Page, hostOS: string): Promise<void> {
|
||||
await page.route("**/*", async (route) => {
|
||||
if (route.request().resourceType() !== "document") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const rewritten = body.replace(/"hostOS":"[^"]*"/, `"hostOS":"${hostOS}"`);
|
||||
if (rewritten === body) {
|
||||
throw new Error("The boot document did not contain runtime.hostOS");
|
||||
}
|
||||
await route.fulfill({ response, body: rewritten });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
type MessageEnvelope = {
|
||||
action?: unknown;
|
||||
id?: unknown;
|
||||
payload?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
|
||||
function asEnvelope(value: unknown): MessageEnvelope | null {
|
||||
return typeof value === "object" && value !== null ? (value as MessageEnvelope) : null;
|
||||
}
|
||||
|
||||
function parseFrame(message: string | Buffer): MessageEnvelope[] | null {
|
||||
if (typeof message !== "string") return null;
|
||||
const frames: MessageEnvelope[] = [];
|
||||
for (const part of message.split("\n")) {
|
||||
if (!part.trim()) continue;
|
||||
try {
|
||||
const parsed = asEnvelope(JSON.parse(part));
|
||||
if (parsed) frames.push(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function rewriteStatusResponses(
|
||||
message: string | Buffer,
|
||||
requestIDs: Set<string>,
|
||||
embeddedVscodeSupported: boolean,
|
||||
): string | Buffer {
|
||||
if (typeof message !== "string") return message;
|
||||
|
||||
let changed = false;
|
||||
const rewritten = message
|
||||
.split("\n")
|
||||
.map((part) => {
|
||||
if (!part.trim()) return part;
|
||||
try {
|
||||
const envelope = asEnvelope(JSON.parse(part));
|
||||
if (
|
||||
envelope?.type !== "response" ||
|
||||
typeof envelope.id !== "string" ||
|
||||
!requestIDs.delete(envelope.id) ||
|
||||
!asEnvelope(envelope.payload)
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
changed = true;
|
||||
return JSON.stringify({
|
||||
...envelope,
|
||||
payload: {
|
||||
...envelope.payload,
|
||||
capabilities: { embedded_vscode: embeddedVscodeSupported },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return part;
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return changed ? rewritten : message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites only task.session.status responses, preserving the real WebSocket
|
||||
* connection and every unrelated frame. This lets browser tests exercise the
|
||||
* session capability contract without duplicating executor policy in E2E code.
|
||||
*/
|
||||
export async function routeSessionEmbeddedVscodeCapability(
|
||||
page: Page,
|
||||
embeddedVscodeSupported: boolean,
|
||||
): Promise<void> {
|
||||
const statusRequestIDs = new Set<string>();
|
||||
await page.routeWebSocket(/\/ws$/, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
for (const envelope of parseFrame(message) ?? []) {
|
||||
if (
|
||||
envelope.type === "request" &&
|
||||
envelope.action === "task.session.status" &&
|
||||
typeof envelope.id === "string"
|
||||
) {
|
||||
statusRequestIDs.add(envelope.id);
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
ws.send(rewriteStatusResponses(message, statusRequestIDs, embeddedVscodeSupported));
|
||||
});
|
||||
});
|
||||
}
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { routeSessionEmbeddedVscodeCapability } from "../../helpers/session-capabilities";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { rewriteBackendHostOS } from "../../helpers/boot-payload";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function createHostedEditor(apiClient: ApiClient): Promise<void> {
|
||||
@@ -26,7 +26,7 @@ async function seedTaskAndOpenEditorMenu(
|
||||
) {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Host platform editor availability",
|
||||
"Executor editor availability",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
@@ -42,32 +42,32 @@ async function seedTaskAndOpenEditorMenu(
|
||||
await page.getByTestId("editors-menu-list").click();
|
||||
}
|
||||
|
||||
test.describe("host platform controls embedded VS Code availability", () => {
|
||||
test("hides embedded VS Code for a Windows backend and keeps custom editors", async ({
|
||||
test.describe("executor capability controls embedded VS Code availability", () => {
|
||||
test("hides embedded VS Code for an unsupported session and keeps custom editors", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
await routeSessionEmbeddedVscodeCapability(testPage, false);
|
||||
await createHostedEditor(apiClient);
|
||||
await rewriteBackendHostOS(testPage, "windows");
|
||||
|
||||
await seedTaskAndOpenEditorMenu(testPage, apiClient, seedData);
|
||||
|
||||
const menu = testPage.getByRole("menu");
|
||||
await expect(menu.getByRole("menuitem", { name: "Hosted Preview" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "VS Code (Embedded)" })).toHaveCount(0);
|
||||
await prCapture.screenshot("windows-host-editor-menu-desktop", {
|
||||
caption: "Windows-hosted Kandev editor menu with embedded VS Code unavailable",
|
||||
await prCapture.screenshot("unsupported-executor-editor-menu-desktop", {
|
||||
caption: "Unsupported session hides embedded VS Code while custom editors remain available",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores a Windows browser platform when the backend host is Linux", async ({
|
||||
test("shows embedded VS Code for a supported session despite a Windows browser", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await createHostedEditor(apiClient);
|
||||
await routeSessionEmbeddedVscodeCapability(testPage, true);
|
||||
await testPage.addInitScript(() => {
|
||||
Object.defineProperty(Navigator.prototype, "platform", {
|
||||
configurable: true,
|
||||
+4
-6
@@ -1,7 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { rewriteBackendHostOS } from "../../helpers/boot-payload";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function seedTask(
|
||||
@@ -16,7 +15,7 @@ async function seedTask(
|
||||
) {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Mobile host platform editor availability",
|
||||
"Mobile executor editor availability",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
@@ -30,13 +29,12 @@ async function seedTask(
|
||||
await session.waitForLoad();
|
||||
}
|
||||
|
||||
test("Windows host keeps the intentional mobile topbar without desktop editor controls", async ({
|
||||
test("executor capability keeps the intentional mobile topbar without desktop editor controls", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
await rewriteBackendHostOS(testPage, "windows");
|
||||
await seedTask(testPage, apiClient, seedData);
|
||||
|
||||
await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible();
|
||||
@@ -46,7 +44,7 @@ test("Windows host keeps the intentional mobile topbar without desktop editor co
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(width.scroll).toBeLessThanOrEqual(width.client + 1);
|
||||
await prCapture.screenshot("windows-host-editor-menu-mobile", {
|
||||
caption: "Mobile task topbar remains compact on a Windows Kandev host",
|
||||
await prCapture.screenshot("executor-editor-menu-mobile", {
|
||||
caption: "Mobile task topbar remains compact for every executor capability",
|
||||
});
|
||||
});
|
||||
@@ -237,4 +237,42 @@ describe("useSessionResumption", () => {
|
||||
await waitFor(() => expect(mockSetTaskSession).toHaveBeenCalled());
|
||||
expect(mockSetTaskSession).toHaveBeenCalledWith(expect.objectContaining({ updated_at: "" }));
|
||||
});
|
||||
|
||||
it("refreshes the status and embedded editor capability after a successful resume", async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
session_id: "s1",
|
||||
task_id: "t1",
|
||||
state: "WAITING_FOR_INPUT",
|
||||
is_agent_running: false,
|
||||
is_resumable: true,
|
||||
needs_resume: true,
|
||||
capabilities: { embedded_vscode: false },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
task_id: "t1",
|
||||
session_id: "s1",
|
||||
state: "STARTING",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
session_id: "s1",
|
||||
task_id: "t1",
|
||||
state: "STARTING",
|
||||
is_agent_running: true,
|
||||
is_resumable: false,
|
||||
needs_resume: false,
|
||||
capabilities: { embedded_vscode: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionResumption("t1", "s1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionStatus?.capabilities?.embedded_vscode).toBe(true);
|
||||
});
|
||||
expect(mockRequest).toHaveBeenLastCalledWith("task.session.status", {
|
||||
task_id: "t1",
|
||||
session_id: "s1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,9 @@ export type SessionStatus = {
|
||||
remote_created_at?: string;
|
||||
remote_checked_at?: string;
|
||||
remote_status_error?: string;
|
||||
capabilities?: {
|
||||
embedded_vscode: boolean;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -106,7 +109,7 @@ async function resumeViaLaunch(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
) => { request: import("@/lib/services/session-launch-service").LaunchSessionRequest },
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
setters.setResumptionState("resuming");
|
||||
const { request } = buildRequest(taskId, sessionId);
|
||||
const launchResp = await launchSession(request);
|
||||
@@ -132,6 +135,7 @@ async function resumeViaLaunch(
|
||||
if (ok && request.intent === "restore_workspace" && setters.setAgentctlReady) {
|
||||
setters.setAgentctlReady(sessionId);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Attempt resume, silently falling back to restore_workspace on any failure.
|
||||
@@ -145,7 +149,7 @@ export async function resumeWithSilentFallback(
|
||||
sessionId: string,
|
||||
session: SessionLike,
|
||||
setters: ResumeStateSetter,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
setters.setResumptionState("resuming");
|
||||
if (
|
||||
await tryLaunch(
|
||||
@@ -156,7 +160,7 @@ export async function resumeWithSilentFallback(
|
||||
setters,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Resume failed (returned success=false OR threw). Fall back to read-only
|
||||
// workspace restore so the user keeps file/terminal/git access.
|
||||
@@ -169,10 +173,11 @@ export async function resumeWithSilentFallback(
|
||||
setters,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
setters.setResumptionState("error");
|
||||
setters.setError("Failed to resume session — workspace restore also unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Run a single launch attempt; returns true on success, false on any failure.
|
||||
@@ -246,6 +251,38 @@ function applyStatusToState(
|
||||
}
|
||||
}
|
||||
|
||||
type RefreshSessionStatusParams = {
|
||||
client: NonNullable<ReturnType<typeof getWebSocketClient>>;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
session: SessionLike;
|
||||
setSessionStatus: (status: SessionStatus) => void;
|
||||
setters: ResumeStateSetter;
|
||||
};
|
||||
|
||||
async function refreshSessionStatus({
|
||||
client,
|
||||
taskId,
|
||||
sessionId,
|
||||
session,
|
||||
setSessionStatus,
|
||||
setters,
|
||||
}: RefreshSessionStatusParams): Promise<void> {
|
||||
try {
|
||||
const status = await client.request<SessionStatus>("task.session.status", {
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
});
|
||||
setSessionStatus(status);
|
||||
applyStatusToState(status, taskId, sessionId, session, setters);
|
||||
if (status.is_agent_running && setters.setAgentctlReady) {
|
||||
setters.setAgentctlReady(sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[refreshSessionStatus] failed to refresh session status", { sessionId, err });
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndResume({
|
||||
taskId,
|
||||
sessionId,
|
||||
@@ -274,15 +311,25 @@ async function checkAndResume({
|
||||
if (status.is_agent_running && setters.setAgentctlReady) {
|
||||
setters.setAgentctlReady(sessionId);
|
||||
}
|
||||
let resumed = false;
|
||||
if (status.is_agent_running) {
|
||||
setters.setResumptionState("running");
|
||||
} else if (status.needs_resume && status.is_resumable) {
|
||||
await resumeWithSilentFallback(taskId, sessionId, session, setters);
|
||||
resumed = await resumeWithSilentFallback(taskId, sessionId, session, setters);
|
||||
} else if (status.needs_workspace_restore) {
|
||||
await resumeViaLaunch(taskId, sessionId, session, setters, buildRestoreWorkspaceRequest);
|
||||
resumed = await resumeViaLaunch(
|
||||
taskId,
|
||||
sessionId,
|
||||
session,
|
||||
setters,
|
||||
buildRestoreWorkspaceRequest,
|
||||
);
|
||||
} else {
|
||||
setters.setResumptionState("idle");
|
||||
}
|
||||
if (resumed) {
|
||||
await refreshSessionStatus({ client, taskId, sessionId, session, setSessionStatus, setters });
|
||||
}
|
||||
} catch (err) {
|
||||
setters.setResumptionState("error");
|
||||
setters.setError(err instanceof Error ? err.message : "Unknown error");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadBootPayload, readBackendHostOS, readBootPayload } from "./boot-payload";
|
||||
import { loadBootPayload, readBootPayload } from "./boot-payload";
|
||||
|
||||
const JIRA_BUNDLE_URL = "/api/plugins/jira/bundle";
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("readBootPayload", () => {
|
||||
runtime: {
|
||||
apiPrefix: "/api/v1",
|
||||
webSocketPath: "/ws",
|
||||
hostOS: "linux",
|
||||
},
|
||||
initialState: {
|
||||
tasks: { activeTaskId: "task-1" },
|
||||
@@ -43,7 +42,6 @@ describe("readBootPayload", () => {
|
||||
runtime: {
|
||||
apiPrefix: "/api/v1",
|
||||
webSocketPath: "/ws",
|
||||
hostOS: "linux",
|
||||
},
|
||||
initialState: {
|
||||
tasks: { activeTaskId: "task-1" },
|
||||
@@ -76,24 +74,6 @@ describe("readBootPayload", () => {
|
||||
expect(readBootPayload(win).runtime?.debug).toBe(true);
|
||||
expect(win.__KANDEV_DEBUG).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores an invalid runtime host OS value", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: {
|
||||
runtime: { hostOS: 42 },
|
||||
},
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBootPayload(win).runtime?.hostOS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the backend host OS from the injected runtime payload", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { hostOS: "windows" } },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBackendHostOS(win)).toBe("windows");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBootPayload plugins", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ export type BootRoute = {
|
||||
export type BootRuntime = {
|
||||
apiPrefix?: string;
|
||||
webSocketPath?: string;
|
||||
hostOS?: string;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
@@ -80,10 +79,6 @@ export function readInterimSettingsInterlockToken(): string | undefined {
|
||||
return readBootPayload(window).interimSettingsInterlockToken;
|
||||
}
|
||||
|
||||
export function readBackendHostOS(win: Window = window): string | undefined {
|
||||
return readBootPayload(win).runtime?.hostOS;
|
||||
}
|
||||
|
||||
function readPlugins(value: unknown[]): ActivePlugin[] {
|
||||
return value.filter(isRecord).flatMap((entry) => {
|
||||
const plugin = readPlugin(entry);
|
||||
@@ -138,7 +133,6 @@ function readRuntime(value: Record<string, unknown>): BootRuntime {
|
||||
return {
|
||||
apiPrefix: readString(value.apiPrefix),
|
||||
webSocketPath: readString(value.webSocketPath),
|
||||
hostOS: readString(value.hostOS),
|
||||
debug: value.debug === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-2026-07-30-embedded-editor-executor-capabilities: Derive Embedded Editor Availability from the Active Executor
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-07-30
|
||||
**Area:** backend, frontend, protocol
|
||||
|
||||
## Context
|
||||
|
||||
Kandev's embedded VS Code integration starts code-server through agentctl inside a task session's
|
||||
execution environment. The original Windows guard instead used `runtime.hostOS` from the SPA boot
|
||||
payload, which describes the Kandev control-plane process. That is correct only for host-backed
|
||||
Local and Worktree executors. It incorrectly disables Linux Docker, Remote Docker, Sprites, and
|
||||
supported SSH environments whenever the Kandev backend itself runs on Windows.
|
||||
|
||||
The task-session status contract already identifies the active session's executor and runtime.
|
||||
Availability is needed before the user launches an editor, and the launch API must enforce the
|
||||
same answer so a stale or handcrafted client cannot bypass the UI.
|
||||
|
||||
## Decision
|
||||
|
||||
Embedded-editor availability is a capability of the active session execution environment, not of
|
||||
the browser or the Kandev installation as a whole.
|
||||
|
||||
The backend is the single authority for this capability. `task.session.status` returns a typed
|
||||
`capabilities.embedded_vscode` boolean. The frontend consumes that boolean and does not maintain an
|
||||
executor-type allowlist.
|
||||
|
||||
The backend resolver maps executor runtime ownership as follows:
|
||||
|
||||
- Standalone Local and Worktree execution uses the Kandev host platform and supports code-server
|
||||
on Linux and macOS, but not Windows.
|
||||
- Docker, Remote Docker, and Sprites execute in supported Linux environments.
|
||||
- SSH supports code-server while Kandev's SSH runtime accepts only Linux and macOS targets. If SSH
|
||||
later accepts Windows, its capability must use persisted or live remote-platform metadata rather
|
||||
than remaining unconditionally true by executor type.
|
||||
- Missing executors, unknown executor types, and unknown runtimes fail closed.
|
||||
|
||||
The same resolver is used by task-session status projection and by the editor service. Requests to
|
||||
open `internal_vscode` are rejected with the existing editor-unavailable error when the resolved
|
||||
capability is false. Other editors retain their existing enabled, installed, and configuration
|
||||
rules.
|
||||
|
||||
The frontend treats a missing capability field as false, filters the topbar editor candidates
|
||||
before resolving the saved default, and keys the value to the currently active session. A saved
|
||||
preference is never rewritten merely because it is incompatible with one session.
|
||||
|
||||
The editor-specific `runtime.hostOS` boot field is removed because it has no remaining consumer.
|
||||
General host diagnostics, if needed later, should use a diagnostic contract rather than influence
|
||||
task-runtime capabilities.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Windows users regain embedded VS Code for Linux-backed Docker and other supported remote task
|
||||
runtimes while native Windows Local and Worktree sessions remain protected.
|
||||
- Availability changes when the active session changes, even within one task.
|
||||
- Capability policy lives beside backend runtime knowledge and can evolve without shipping a
|
||||
matching frontend executor list.
|
||||
- UI filtering and API enforcement cannot drift if both use the shared resolver.
|
||||
- Until session status arrives, the embedded editor is hidden. Other configured editors remain
|
||||
available.
|
||||
- Adding a new executor or expanding an existing executor to another platform requires an explicit
|
||||
capability decision and tests.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Keep using the Kandev backend host OS.** Rejected because it conflates the control plane with
|
||||
Docker, sandbox, and remote execution environments.
|
||||
2. **Hardcode supported executor names in React.** Rejected because it duplicates backend runtime
|
||||
knowledge, cannot safely represent future platform-dependent SSH support, and offers no API
|
||||
enforcement.
|
||||
3. **Expose only executor type and let every client infer support.** Rejected because every client
|
||||
would need to reproduce the capability matrix and update in lockstep.
|
||||
4. **Probe code-server only after the user clicks.** Rejected because unsupported actions remain
|
||||
visible, failures arrive late, and saved-default fallback cannot be resolved beforehand.
|
||||
5. **Replace code-server with Microsoft VS Code Server.** Rejected for this change because it is a
|
||||
separate product and licensing/integration decision and is unnecessary for Windows-hosted Linux
|
||||
executors.
|
||||
@@ -88,4 +88,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 |
|
||||
| 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 |
|
||||
| 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 |
|
||||
| 2026-07-30-embedded-editor-executor-capabilities | [Derive Embedded Editor Availability from the Active Executor](2026-07-30-embedded-editor-executor-capabilities.md) | accepted | backend, frontend, protocol | 2026-07-30 |
|
||||
| 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 |
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
spec: docs/specs/ui/embedded-vscode-executor-availability.md
|
||||
created: 2026-07-30
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Embedded VS Code Executor Availability
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the installation-wide backend-host check with a backend-owned capability for the active
|
||||
task session. Compute the capability from the executor runtime and host platform, expose it through
|
||||
the existing task-session status response, enforce it in the open-editor service, and make the
|
||||
desktop topbar filter and fallback follow the active session. Remove the obsolete boot
|
||||
`runtime.hostOS` field, prove the backend matrix and frontend session behavior with focused tests
|
||||
and Playwright, and update public executor/editor guidance.
|
||||
|
||||
## Backend
|
||||
|
||||
### Shared executor capability
|
||||
|
||||
- Add a small backend-owned resolver under
|
||||
`apps/backend/internal/editors/capabilities/`.
|
||||
- The resolver accepts `models.ExecutorType` and the control-plane OS rather than reading browser
|
||||
or request data. It implements the spec's explicit executor matrix:
|
||||
- Local and Worktree are supported on Linux or macOS and unsupported on Windows or an unknown
|
||||
host OS;
|
||||
- Local Docker, Remote Docker, Sprites, and SSH are supported by their currently accepted task
|
||||
platforms;
|
||||
- unknown, empty, and test-only executor types fail closed.
|
||||
- Cover every executor type/OS combination in
|
||||
`apps/backend/internal/editors/capabilities/capabilities_test.go`.
|
||||
- Keep the policy in this shared backend package. Neither the orchestrator, editor service, nor
|
||||
frontend may reproduce the matrix.
|
||||
|
||||
### Session capability contract
|
||||
|
||||
- Add a typed capabilities DTO to
|
||||
`apps/backend/internal/orchestrator/dto/dto.go`:
|
||||
|
||||
```text
|
||||
capabilities.embedded_vscode boolean
|
||||
```
|
||||
|
||||
- Populate it in `apps/backend/internal/orchestrator/task_operations.go` while resolving the
|
||||
session executor. Authorized existing sessions always return the nested capability object;
|
||||
missing or unknown executor data yields `false`.
|
||||
- Extend focused task-session status tests in
|
||||
`apps/backend/internal/orchestrator/task_operations_test.go` (or the closest existing
|
||||
status-specific test file) to assert that a known executor uses the shared resolver with
|
||||
`runtime.GOOS`, the nested boolean is serialized, and unresolved executor data is false. The
|
||||
capability-package table owns simulated Windows/non-Windows matrix coverage without adding
|
||||
production injection points.
|
||||
|
||||
### Open-editor enforcement
|
||||
|
||||
- Extend the editor service's task repository boundary in
|
||||
`apps/backend/internal/editors/service/service.go` so it can resolve the selected session's
|
||||
executor.
|
||||
- Before returning an `internal://vscode` sentinel, evaluate the shared capability and return
|
||||
`ErrEditorUnavailable` when false.
|
||||
- Apply the check to folder-level and file-level embedded-editor opens. Do not change other editor
|
||||
kinds.
|
||||
- Extend `apps/backend/internal/editors/service/service_test.go` with supported and unsupported
|
||||
session/executor fixtures, including the saved-default path. Preserve the handlers' existing
|
||||
HTTP 409 mapping for `ErrEditorUnavailable`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Consume active-session capability
|
||||
|
||||
- Add the nested capability shape to `SessionStatus` in
|
||||
`apps/web/hooks/domains/session/use-session-resumption.ts`.
|
||||
- Read `resumption.sessionStatus.capabilities.embedded_vscode` in
|
||||
`apps/web/components/task/task-page-inner.tsx` and pass the boolean through
|
||||
`TaskTopBar` → `TopBarRight` → `TopbarToolsGroup` → `EditorsMenu`.
|
||||
- The value must be derived from the current `effectiveSessionId`; a missing status or field is
|
||||
`false`, preventing a previous session's capability from leaking across a tab switch.
|
||||
- Update `apps/web/components/task/task-top-bar.test.tsx` to capture the mocked `EditorsMenu` props
|
||||
and prove the capability is forwarded.
|
||||
|
||||
### Filter and fallback
|
||||
|
||||
- Change `getAvailableTaskTopbarEditors` in
|
||||
`apps/web/components/task/editors-menu-availability.ts` to accept
|
||||
`embeddedVscodeSupported: boolean` instead of a host OS.
|
||||
- Continue applying enabled and built-in installed checks first, then remove only
|
||||
`internal_vscode` when the capability is false.
|
||||
- Keep `resolveTaskTopbarEditorId` operating on the filtered set so an incompatible saved default
|
||||
falls back without being persisted.
|
||||
- Update `apps/web/components/task/editors-menu-availability.test.ts` with supported,
|
||||
unsupported, missing-capability, fallback, and other-editor cases.
|
||||
|
||||
### Retire host-wide boot metadata
|
||||
|
||||
- Remove `HostOS` and its construction/assertions from:
|
||||
- `apps/backend/internal/webapp/payload.go`
|
||||
- `apps/backend/internal/backendapp/helpers.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- Remove `BootRuntime.hostOS`, `readBackendHostOS`, and their tests from:
|
||||
- `apps/web/src/boot-payload.ts`
|
||||
- `apps/web/src/boot-payload.test.ts`
|
||||
- Remove the now-unused Playwright boot rewrite helper
|
||||
`apps/web/e2e/helpers/boot-payload.ts`.
|
||||
- Confirm there are no remaining editor-availability reads of backend host metadata.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- Desktop outcome: the existing split editor control remains in the desktop task topbar and
|
||||
changes availability when the active session changes.
|
||||
- Mobile entry point and hierarchy: `TaskPageInner` continues to render
|
||||
`SessionMobileTopBar` instead of `TaskTopBar`; no editor action, drawer, route, or touch target is
|
||||
added.
|
||||
- Nearest shipped exemplar:
|
||||
`apps/web/components/task/mobile/session-mobile-top-bar.tsx`.
|
||||
- Presentation and state: the current mobile title, repository, git, plugin, executor, and task
|
||||
switcher ordering remains unchanged. Capability loading introduces no mobile scroll owner,
|
||||
safe-area, or persistence change.
|
||||
- Coverage: retain a phone-viewport Playwright assertion that desktop editor controls are absent
|
||||
and the document does not overflow horizontally.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** executor and host-OS combinations produce the capability matrix.
|
||||
**File:** `apps/backend/internal/editors/capabilities/capabilities_test.go`.
|
||||
**How:** table-driven tests cover every executor type, Windows/Linux/macOS host-backed execution,
|
||||
and unknown values.
|
||||
- **What:** task-session status exposes a backend-owned capability for the session executor.
|
||||
**File:** `apps/backend/internal/orchestrator/task_operations_test.go` or its nearest focused
|
||||
sibling.
|
||||
**How:** executor fixtures assert the known executor's resolver result for `runtime.GOOS`, the
|
||||
serialized nested field, and missing executor data.
|
||||
- **What:** direct embedded-editor opens cannot bypass capability filtering.
|
||||
**File:** `apps/backend/internal/editors/service/service_test.go`.
|
||||
**How:** service tests select `internal_vscode` explicitly and through the saved default for
|
||||
supported and unsupported sessions, while a non-embedded editor remains unchanged.
|
||||
- **What:** the topbar editor set and default fallback use capability state.
|
||||
**File:** `apps/web/components/task/editors-menu-availability.test.ts`.
|
||||
**How:** table-driven Vitest cases pass true/false and mixed `EditorOption` fixtures.
|
||||
- **What:** the active-session capability reaches the menu and missing data fails closed.
|
||||
**Files:** `apps/web/components/task/task-top-bar.test.tsx` and, if needed for the
|
||||
`TaskPageInner` boundary, a focused helper test extracted beside
|
||||
`apps/web/components/task/task-page-inner.tsx`.
|
||||
**How:** assert the prop chain for supported, unsupported, and absent status data.
|
||||
- **What:** boot host metadata is fully retired.
|
||||
**Files:** `apps/backend/internal/backendapp/helpers_test.go` and
|
||||
`apps/web/src/boot-payload.test.ts`.
|
||||
**How:** remove obsolete assertions and retain normalization/serialization coverage for the
|
||||
remaining runtime fields.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Replace
|
||||
`apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts` with
|
||||
`apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts`.
|
||||
- Add a small WebSocket routing helper in
|
||||
`apps/web/e2e/helpers/session-capabilities.ts` that proxies the real socket and rewrites only the
|
||||
matching `task.session.status` response's `capabilities.embedded_vscode` value. Backend unit tests
|
||||
own the executor matrix; this helper owns the browser's response to the contract.
|
||||
- **Scenario:** an unsupported active session hides **VS Code (Embedded)**, retains a hosted custom
|
||||
editor, and uses the custom editor when the saved embedded default is unavailable.
|
||||
- **Scenario:** a supported active session shows **VS Code (Embedded)** even when
|
||||
`navigator.platform` reports Windows, proving browser platform is irrelevant.
|
||||
- **Scenario:** switching the active session from an unsupported capability to a supported
|
||||
capability updates the menu without a page reload, if the existing session fixture can express
|
||||
the switch without broad setup. Otherwise cover the stale-session guard in focused Vitest and
|
||||
keep Playwright to the two contract cases above.
|
||||
- Rename
|
||||
`apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts` to
|
||||
`apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts`; remove the boot
|
||||
payload rewrite and retain the compact-header/no-overflow assertions at the phone viewport.
|
||||
- Existing `apps/web/e2e/tests/settings/vscode-open-panel.spec.ts` remains the supported-runtime
|
||||
launch smoke test.
|
||||
|
||||
## Public documentation
|
||||
|
||||
- Update `docs/public/developer-tools.md` to say that embedded VS Code runs in the active task
|
||||
environment. Native Windows Local/Worktree sessions are unsupported, while Linux Docker and
|
||||
supported remote executors remain available even when the Kandev app runs on Windows.
|
||||
- Update the **Embedded VS Code** row in `docs/public/feature-status.md` with the executor-specific
|
||||
boundary.
|
||||
- Update `docs/public/executors.md` with a concise availability matrix or cross-link if that makes
|
||||
the executor distinction easier to find.
|
||||
- Keep the code-server release link and existing `--auth none` network-isolation warning.
|
||||
|
||||
## Implementation waves and parallel candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Add and enforce the session capability](task-01-add-session-capability.md)
|
||||
(`done`)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Wire active-session editor availability](task-02-wire-editor-availability.md)
|
||||
(`done`, depends on Task 01)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [Task 03: Prove executor-specific behavior](task-03-executor-availability-e2e.md)
|
||||
(`done`, depends on Task 02)
|
||||
- [x] [Task 04: Document executor-specific availability](task-04-document-executor-availability.md)
|
||||
(`done`, depends on Tasks 01 and 02; parallel-safe with Task 03 only if the user explicitly
|
||||
authorizes subagents)
|
||||
|
||||
The primary conversation executes these tasks sequentially unless the user explicitly authorizes
|
||||
subagents.
|
||||
|
||||
## Risks
|
||||
|
||||
- `task.session.status` is asynchronous. The frontend must fail closed for embedded VS Code without
|
||||
disabling unrelated editors or retaining the previous session's capability.
|
||||
- Executor type is not permanently equivalent to operating system. SSH is currently safe because
|
||||
Kandev accepts only Linux/macOS remotes; future Windows SSH support must add remote-platform-aware
|
||||
capability resolution.
|
||||
- UI filtering alone is insufficient because file-level and other callers share the open-editor
|
||||
API. The editor service guard must use the same backend resolver.
|
||||
- Removing `runtime.hostOS` spans Go boot serialization, TypeScript normalization, and old
|
||||
Playwright helpers. A repository-wide search is required before deletion is considered complete.
|
||||
- A true capability means the runtime is supported, not that a particular download or startup will
|
||||
succeed. Existing runtime errors must remain visible.
|
||||
- E2E WebSocket rewriting must preserve every unrelated frame and correlate the exact status
|
||||
request/response ID; otherwise the test could hide real connection behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
From `apps/backend/`:
|
||||
|
||||
```bash
|
||||
go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers
|
||||
```
|
||||
|
||||
From `apps/`:
|
||||
|
||||
```bash
|
||||
pnpm --filter @kandev/web test -- src/boot-payload.test.ts components/task/editors-menu-availability.test.ts components/task/task-top-bar.test.tsx
|
||||
pnpm --filter @kandev/web typecheck
|
||||
pnpm --filter @kandev/web lint
|
||||
pnpm --filter @kandev/web e2e:run -- tests/task/executor-embedded-vscode-availability.spec.ts -- --project=chromium
|
||||
pnpm --filter @kandev/web e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts -- --project=mobile-chrome
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
Implementation records the red/green results in each task file, updates task/plan statuses, and
|
||||
moves the spec from `approved` to `building` at start and `shipped` only after every acceptance
|
||||
scenario and verification command passes.
|
||||
|
||||
## Completion record
|
||||
|
||||
All four tasks completed on 2026-07-30. Backend capability and enforcement tests, focused web
|
||||
tests, typecheck, lint, both targeted Playwright suites, and public-doc validation passed. See the
|
||||
individual task records for exact commands and results.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "01-add-session-capability"
|
||||
title: "Add and enforce the session capability"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/embedded-vscode-executor-availability.md"
|
||||
---
|
||||
|
||||
# Task 01: Add and Enforce the Session Capability
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A shared backend resolver returns the specified embedded-VS-Code capability for every known
|
||||
runtime/host-OS combination and fails closed for unknown values.
|
||||
- Every authorized task-session status response includes
|
||||
`capabilities.embedded_vscode` for that session's executor.
|
||||
- Opening `internal_vscode` directly or through the saved default returns
|
||||
`ErrEditorUnavailable` for an unsupported session while supported sessions and other editor kinds
|
||||
retain their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: add the executor matrix, status contract, and editor-service rejection tests; observe the
|
||||
focused failures; implement the shared resolver and both consumers; then rerun from
|
||||
`apps/backend/`:
|
||||
|
||||
```bash
|
||||
go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/editors/capabilities/capabilities.go` (new)
|
||||
- `apps/backend/internal/editors/capabilities/capabilities_test.go` (new)
|
||||
- `apps/backend/internal/orchestrator/dto/dto.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations.go`
|
||||
- `apps/backend/internal/orchestrator/task_operations_test.go`
|
||||
- `apps/backend/internal/editors/service/service.go`
|
||||
- `apps/backend/internal/editors/service/service_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. This task owns the backend contract and shared enforcement consumed by Task 02.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **API surface**, **Failure modes**, and scenarios 1–7.
|
||||
- ADR:
|
||||
`docs/decisions/2026-07-30-embedded-editor-executor-capabilities.md`.
|
||||
- Existing patterns:
|
||||
- executor constants in `apps/backend/internal/task/models/models.go`
|
||||
- `TaskSessionStatusResponse` in `apps/backend/internal/orchestrator/dto/dto.go`
|
||||
- `populateExecutorStatusInfo` in `apps/backend/internal/orchestrator/task_operations.go`
|
||||
- `ErrEditorUnavailable` in `apps/backend/internal/editors/service/service.go`
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not let unknown or test-only executor values inherit the default standalone runtime.
|
||||
- Use actual session executor data for enforcement; do not trust a client-supplied executor type.
|
||||
- Keep the editor service's repository interface minimal and update its test doubles without
|
||||
broadening unrelated dependencies.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the API shape and capability matrix implemented, files changed, red and green command
|
||||
results, and any remaining risk. Update this task to `done`, its plan checkbox/status, and the spec
|
||||
to `building`.
|
||||
|
||||
## Completion record
|
||||
|
||||
- Added `editors/capabilities.SupportsEmbeddedVscode`, which accepts only Linux/Darwin for Local
|
||||
and Worktree, accepts the supported container/remote executor types, and fails closed otherwise.
|
||||
- `task.session.status` now always includes `capabilities.embedded_vscode`; missing executors
|
||||
return `false`. Direct internal-editor requests resolve the session executor and return
|
||||
`ErrEditorUnavailable` when unsupported, for saved-default, explicit, folder, and file paths.
|
||||
- Red: the resolver/status/service tests initially failed to compile or returned a successful
|
||||
internal-editor URL before the capability contract and guard were added.
|
||||
- Green: `go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers` passed.
|
||||
- Remaining risk: a true capability identifies a supported runtime only; code-server download and
|
||||
startup retain their existing runtime error handling.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
id: "02-wire-editor-availability"
|
||||
title: "Wire active-session editor availability"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-add-session-capability"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/embedded-vscode-executor-availability.md"
|
||||
---
|
||||
|
||||
# Task 02: Wire Active-Session Editor Availability
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The desktop task topbar filters `internal_vscode` with the active session's backend capability;
|
||||
missing capability data fails closed without hiding unrelated editors.
|
||||
- Saved-default fallback and active-session changes use the filtered editor set without mutating
|
||||
the stored preference or leaking the previous session's value.
|
||||
- The obsolete boot `runtime.hostOS` field, parser/accessor, tests, and Playwright helper are
|
||||
removed with no remaining consumer.
|
||||
|
||||
## Verification
|
||||
|
||||
Use TDD: update the availability and prop-flow tests to describe the capability contract, observe
|
||||
the failures, wire the active-session value, and remove host metadata only after the new path is
|
||||
green. From `apps/` run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @kandev/web test -- src/boot-payload.test.ts components/task/editors-menu-availability.test.ts components/task/task-top-bar.test.tsx
|
||||
pnpm --filter @kandev/web typecheck
|
||||
pnpm --filter @kandev/web lint
|
||||
```
|
||||
|
||||
From `apps/backend/` run the affected boot package tests:
|
||||
|
||||
```bash
|
||||
go test ./internal/backendapp ./internal/webapp
|
||||
```
|
||||
|
||||
Finally search from the repository root:
|
||||
|
||||
```bash
|
||||
rg -n "readBackendHostOS|hostOS|rewriteBackendHostOS" apps
|
||||
```
|
||||
|
||||
The search must return no obsolete editor-availability or boot-contract references.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/domains/session/use-session-resumption.ts`
|
||||
- `apps/web/components/task/task-page-inner.tsx`
|
||||
- `apps/web/components/task/task-top-bar.tsx`
|
||||
- `apps/web/components/task/task-top-bar.test.tsx`
|
||||
- `apps/web/components/task/editors-menu.tsx`
|
||||
- `apps/web/components/task/editors-menu-availability.ts`
|
||||
- `apps/web/components/task/editors-menu-availability.test.ts`
|
||||
- `apps/web/src/boot-payload.ts`
|
||||
- `apps/web/src/boot-payload.test.ts`
|
||||
- `apps/backend/internal/webapp/payload.go`
|
||||
- `apps/backend/internal/backendapp/helpers.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- `apps/web/e2e/helpers/boot-payload.ts` (remove)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 provides the session capability and enforcement contract.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The frontend contract and metadata removal must land together before E2E coverage.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **API surface**, **Failure modes**, and scenarios 1–6 and 8.
|
||||
- Plan sections: **Consume active-session capability**, **Filter and fallback**, **Retire
|
||||
host-wide boot metadata**, and **Mobile design contract**.
|
||||
- Existing active-session source:
|
||||
`resumption.sessionStatus` in `apps/web/components/task/task-page-inner.tsx`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Reset capability to false when `effectiveSessionId` changes instead of retaining asynchronous
|
||||
status from the previous session.
|
||||
- Pass one capability value to both halves of the split editor control.
|
||||
- Keep custom, hosted, Remote SSH, and installed built-in editors governed by their current rules.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the prop/data flow, removed host metadata, files changed, red and green results, and the
|
||||
repository-wide search result. Update this task to `done` and its plan checkbox/status.
|
||||
|
||||
## Completion record
|
||||
|
||||
- The current session's `capabilities.embedded_vscode` flows from session resumption through
|
||||
`TaskPageInner`, `TaskTopBar`, `TopBarRight`, `TopbarToolsGroup`, and `EditorsMenu`. Missing
|
||||
data is false, while the hook's ID-keyed state prevents a previous session's status leaking into
|
||||
the next session.
|
||||
- Availability filtering now accepts the capability boolean and retains existing enabled/installed
|
||||
checks. The filtered set continues to drive saved-default fallback without persisting a change.
|
||||
- Removed boot `runtime.hostOS` from Go serialization and TypeScript parsing, plus the old E2E
|
||||
HTML-rewrite helper.
|
||||
- Red: the updated availability test still used the old host-OS argument and failed before the
|
||||
filtering implementation changed. Green: focused Vitest tests passed (15 tests),
|
||||
`pnpm run typecheck` and `pnpm run lint` passed, and
|
||||
`go test ./internal/backendapp ./internal/webapp` passed.
|
||||
- `rg -n "readBackendHostOS|rewriteBackendHostOS|hostOS" apps/web apps/backend/internal/webapp apps/backend/internal/backendapp -g '*.ts' -g '*.tsx' -g '*.go'` returned no matches.
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
id: "03-executor-availability-e2e"
|
||||
title: "Prove executor-specific behavior"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["02-wire-editor-availability"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/embedded-vscode-executor-availability.md"
|
||||
---
|
||||
|
||||
# Task 03: Prove Executor-Specific Behavior
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop Playwright coverage proves unsupported capability hides embedded VS Code while retaining
|
||||
a compatible editor, and supported capability shows it independently of browser platform.
|
||||
- Capability test routing rewrites only the correlated `task.session.status` response and proxies
|
||||
every unrelated WebSocket frame unchanged.
|
||||
- Phone-viewport coverage retains the intentional mobile topbar, absence of desktop editor
|
||||
controls, and no horizontal overflow without relying on boot host metadata.
|
||||
|
||||
## Verification
|
||||
|
||||
From `apps/`:
|
||||
|
||||
```bash
|
||||
pnpm --filter @kandev/web e2e:run -- tests/task/executor-embedded-vscode-availability.spec.ts -- --project=chromium
|
||||
pnpm --filter @kandev/web e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts -- --project=mobile-chrome
|
||||
```
|
||||
|
||||
If the WebSocket helper has extractable pure frame-rewrite logic, add and run its focused Vitest
|
||||
test before Playwright. Do not add a test-only production API or runtime flag.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/helpers/session-capabilities.ts` (new)
|
||||
- `apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts` (replace old
|
||||
host-platform spec)
|
||||
- `apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts` (rename and update)
|
||||
- `apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts` (remove)
|
||||
- `apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts` (remove)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 supplies the active-session UI behavior and removes the boot-host helper.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 04 only when the user explicitly authorizes subagents. Otherwise run
|
||||
sequentially in the primary conversation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios 1–3, 8, and 9.
|
||||
- Plan section: **E2E tests**.
|
||||
- Existing WebSocket proxy pattern: `apps/web/e2e/helpers/ws-drop.ts`.
|
||||
- Existing supported launch smoke:
|
||||
`apps/web/e2e/tests/settings/vscode-open-panel.spec.ts`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Correlate request IDs; do not mutate every response containing a `capabilities` key.
|
||||
- Preserve newline-delimited frames and binary frames exactly when they are not the target.
|
||||
- Avoid starting code-server in menu-availability tests; the existing launch smoke owns that cost.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report scenarios covered, files replaced/removed, exact Playwright results, and any fixture
|
||||
limitations. Update this task to `done` and its plan checkbox/status.
|
||||
|
||||
## Completion record
|
||||
|
||||
- Replaced both host-platform specs with executor-capability desktop and mobile specs. Added a
|
||||
WebSocket proxy helper that records only `task.session.status` request IDs, rewrites only their
|
||||
response capability, and forwards all other frames unchanged.
|
||||
- `./e2e/scripts/run-e2e.sh --no-build --project chromium -- tests/task/executor-embedded-vscode-availability.spec.ts` passed: 2 tests.
|
||||
- `pnpm e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts --project=mobile-chrome` passed: 1 test.
|
||||
- The desktop suite covers unsupported-session filtering with a retained custom editor and a
|
||||
supported capability under a Windows browser platform. The mobile suite confirms the compact
|
||||
header, no desktop editor control, and no horizontal overflow.
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: "04-document-executor-availability"
|
||||
title: "Document executor-specific availability"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["01-add-session-capability", "02-wire-editor-availability"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/embedded-vscode-executor-availability.md"
|
||||
---
|
||||
|
||||
# Task 04: Document Executor-Specific Availability
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public editor guidance distinguishes native Windows Local/Worktree sessions from supported Linux
|
||||
Docker and remote task environments on a Windows Kandev host.
|
||||
- The feature-status boundary and executor documentation agree with the approved spec and retain
|
||||
the code-server network-isolation/startup caveats.
|
||||
- Public-doc validation passes, and the spec/plan/task statuses accurately reflect the completed
|
||||
implementation and recorded verification.
|
||||
|
||||
## Verification
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
Cross-check terminology and stale host-wide claims:
|
||||
|
||||
```bash
|
||||
rg -n "Embedded VS Code|code-server|backend host is Windows|backend runs on Windows" docs/public docs/specs
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `docs/public/developer-tools.md`
|
||||
- `docs/public/feature-status.md`
|
||||
- `docs/public/executors.md`
|
||||
- `docs/specs/ui/embedded-vscode-executor-availability.md`
|
||||
- `docs/specs/INDEX.md`
|
||||
- `docs/plans/embedded-vscode-executor-availability/plan.md`
|
||||
- `docs/plans/embedded-vscode-executor-availability/task-*.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Tasks 01 and 02 establish the final API and user-visible behavior.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with Task 03 only when the user explicitly authorizes subagents. Otherwise run
|
||||
sequentially in the primary conversation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: **What**, **Failure modes**, and **Out of scope**.
|
||||
- Existing public guidance:
|
||||
- `docs/public/developer-tools.md#files-and-editor-integrations`
|
||||
- **Embedded VS Code** row in `docs/public/feature-status.md`
|
||||
- executor boundaries in `docs/public/executors.md`
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not claim that capability guarantees a successful download or startup.
|
||||
- Do not imply native Windows code-server support.
|
||||
- Keep external host-editor discovery separate from the embedded task-runtime editor.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report public pages changed and validator results. After all implementation and E2E checks pass,
|
||||
mark this task and every plan checkbox `done`, set the plan to `complete`, and set the spec to
|
||||
`shipped`.
|
||||
|
||||
## Completion record
|
||||
|
||||
- Updated `developer-tools.md`, `feature-status.md`, and `executors.md` to distinguish native
|
||||
Windows Local/Worktree from Linux-backed Docker, Sprites, and supported SSH execution, while
|
||||
retaining the code-server isolation and startup caveats.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` passed (58 tests) and
|
||||
`node scripts/validate-public-docs.mjs` validated 41 published pages.
|
||||
@@ -132,7 +132,7 @@ For an idle, non-archived repository-backed task, **Files → Workspace actions
|
||||
|
||||
The task **Files** panel browses, searches, opens, and edits task-worktree files. Kandev rejects file paths that escape the resolved worktree. A session with one worktree opens that worktree directly in a host editor. When a session has several worktrees, the editor button asks which repository or worktree to open, and each configured editor in the adjacent menu expands to the same repository-and-branch picker. Check that selection before launching an editor from a multi-repository task. Older API clients that omit `worktree_id` retain the first-worktree fallback.
|
||||
|
||||
**VS Code (Embedded)** runs code-server inside the task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported host platform and access to the [code-server release](https://github.com/coder/code-server/releases/tag/v4.130.0). Kandev does not have a supported standalone Windows code-server installation for this integration, so the task-detail topbar omits **VS Code (Embedded)** when the Kandev backend runs on Windows. This is based on the Kandev backend host, not the visitor's browser platform; other configured editors remain available. Use the panel error and task-environment logs when installation, startup, or proxying fails.
|
||||
**VS Code (Embedded)** runs code-server inside the active task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported execution platform and access to the [code-server release](https://github.com/coder/code-server/releases/tag/v4.130.0). Native Windows Local and Worktree sessions do not support this integration. Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when the Kandev app runs on Windows. The task-detail topbar follows the active session's executor capability, never the visitor's browser platform; other configured editors remain available. Use the panel error and task-environment logs when installation, startup, or proxying fails.
|
||||
|
||||
Use the workbench top bar's split-editor action to open the selected session worktree in the default editor. Its menu lets you choose another configured editor. A file's **Open with** menu can also open a specific editor, copy the path, or ask the operating system to show the folder.
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ An executor determines where Kandev creates a task environment and runs `agentct
|
||||
|
||||
Remote Docker deserves explicit treatment: the backend registers the runtime type, but its create and stop methods return `remote_docker runtime is not yet implemented`. The current **Settings > Executors** hub does not offer it. Older routes and stored fields such as `docker_host`, `docker_tls_verify`, and `docker_cert_path` do not make it operational.
|
||||
|
||||
## Embedded VS Code availability
|
||||
|
||||
**VS Code (Embedded)** starts code-server inside the active task environment, so its availability
|
||||
follows that session's executor rather than the operating system of the browser or desktop app.
|
||||
It is available for Local and Worktree sessions on Linux or macOS, and for Linux-backed Local
|
||||
Docker, Sprites, and supported SSH sessions. Native Windows Local and Worktree sessions do not
|
||||
offer it. See [Developer tools](developer-tools.md#files-and-editor-integrations) for code-server network and
|
||||
download requirements.
|
||||
|
||||
## Create and select a profile
|
||||
|
||||
Open **Settings > Executors**, then choose **Local**, **Worktree**, **Docker**, **Sprites.dev**, or **SSH** under **Create New Profile**. Local and Worktree profiles already exist in a new database.
|
||||
|
||||
@@ -54,7 +54,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
|
||||
| Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. |
|
||||
| Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. |
|
||||
| Voice input | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. |
|
||||
| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:<random-port>`. It relies on task-runtime and network isolation rather than its own login. The task-detail topbar omits it when the Kandev backend host is Windows; this host boundary is independent of the visitor's browser platform, and other configured editors remain available. |
|
||||
| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:<random-port>`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. |
|
||||
| Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. |
|
||||
| Language servers | Limited | Current built-in mapping covers TypeScript/JavaScript, Go, Rust, and Python. Auto-start/installation default off, and subprocesses run on the backend host rather than inside Docker, SSH, or Sprites runtimes. |
|
||||
| Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. |
|
||||
|
||||
+2
-1
@@ -141,7 +141,8 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
| [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped |
|
||||
| [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped |
|
||||
| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped |
|
||||
| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | shipped |
|
||||
| [embedded-vscode-executor-availability](ui/embedded-vscode-executor-availability.md) | approved |
|
||||
| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | archived; superseded by embedded-vscode-executor-availability |
|
||||
|
||||
## system-page/ — operational diagnostics & maintenance UI
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
status: shipped
|
||||
created: 2026-07-30
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Embedded VS Code Executor Availability
|
||||
|
||||
## Why
|
||||
|
||||
Kandev starts **VS Code (Embedded)** as a code-server process inside the active task session's
|
||||
execution environment. The Kandev backend host and that execution environment can use different
|
||||
operating systems: a Windows desktop can run a Linux Docker task, while a Linux Kandev server can
|
||||
control another supported remote runtime.
|
||||
|
||||
The previous host-wide Windows rule therefore hid a working embedded editor from every task on a
|
||||
Windows Kandev host, including Linux-backed Docker tasks. Availability must follow the environment
|
||||
that will actually run code-server.
|
||||
|
||||
## What
|
||||
|
||||
- The active task session's executor runtime is authoritative for **VS Code (Embedded)**
|
||||
availability. The Kandev backend host matters only when that executor runs directly on the host.
|
||||
- The backend reports an explicit `embedded_vscode` capability for each task-session status
|
||||
response. The frontend does not reconstruct support from executor names or browser platform
|
||||
data.
|
||||
- Kandev computes support as follows:
|
||||
|
||||
| Executor type | Execution platform used for the decision | Embedded VS Code |
|
||||
| --- | --- | --- |
|
||||
| Local / Local PC (`local`) | Kandev backend host | Available on Linux and macOS; unavailable on Windows |
|
||||
| Worktree (`worktree`) | Kandev backend host | Available on Linux and macOS; unavailable on Windows |
|
||||
| Local Docker (`local_docker`) | Linux container | Available, including when Kandev runs on Windows |
|
||||
| Remote Docker (`remote_docker`) | Linux container | Available |
|
||||
| Sprites (`sprites`) | Linux sandbox | Available |
|
||||
| SSH (`ssh`) | Supported remote platform | Available while SSH execution remains limited to Linux and macOS |
|
||||
| Unknown or unresolved executor | Unknown | Unavailable |
|
||||
|
||||
- When Kandev later supports an SSH or other executor on additional operating systems, the backend
|
||||
capability resolver must be updated before advertising embedded-editor support there.
|
||||
- The task-detail topbar's primary editor action and adjacent menu use the active session's
|
||||
capability. Switching sessions updates the compatible editor set.
|
||||
- The existing enabled and installed checks still apply to every editor. Only
|
||||
`internal_vscode` is affected by the new capability.
|
||||
- If a saved default is unavailable for the active session, the primary action uses the first
|
||||
remaining compatible editor without changing the saved preference.
|
||||
- If no compatible editor remains, both topbar editor controls are disabled and no editor is
|
||||
launched.
|
||||
- A direct request to open `internal_vscode` for an unsupported or unresolved session is rejected
|
||||
as an unavailable editor. UI filtering is not the enforcement boundary.
|
||||
- The visitor's browser or WebView operating system never controls this capability.
|
||||
- The phone task-detail layout remains unchanged. It continues to use its compact mobile topbar
|
||||
without the desktop editor action.
|
||||
|
||||
## API surface
|
||||
|
||||
The `task.session.status` WebSocket response includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": {
|
||||
"embedded_vscode": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`capabilities` is backend-owned session execution metadata. `embedded_vscode` is always a boolean
|
||||
for an authorized, existing session. Missing capability data from an older backend, a missing
|
||||
executor, or an unrecognized runtime is treated as `false` by the frontend.
|
||||
|
||||
`POST /api/v1/task-sessions/:id/open-editor` returns the existing editor-unavailable conflict
|
||||
response when `internal_vscode` is selected for a session whose capability is false.
|
||||
|
||||
The boot runtime's `hostOS` field is removed because it is no longer an editor-availability
|
||||
contract and has no other consumer.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If task-session status has not loaded yet, the embedded editor is withheld while other enabled
|
||||
editors remain usable.
|
||||
- If the executor lookup or runtime mapping fails, the backend reports
|
||||
`embedded_vscode: false`.
|
||||
- A stale or handcrafted client cannot bypass the rule: the open-editor service resolves the
|
||||
session executor and rejects unsupported embedded-editor requests.
|
||||
- A supported runtime can still fail during code-server download or startup. Existing panel errors
|
||||
and task-environment logs remain the diagnostic surface for network, archive, process, and proxy
|
||||
failures.
|
||||
- Changing active sessions cannot reuse the previous session's capability.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** Kandev runs on Windows and the active session uses Local or Worktree, **WHEN** a user
|
||||
opens the task-detail editor menu, **THEN** **VS Code (Embedded)** is absent and other compatible
|
||||
editors remain available.
|
||||
- **GIVEN** Kandev runs on Windows and the active session uses Local Docker, **WHEN** a user opens
|
||||
the task-detail editor menu, **THEN** **VS Code (Embedded)** is available.
|
||||
- **GIVEN** a task has one unsupported local session and one supported Docker session, **WHEN** the
|
||||
user switches between them, **THEN** the editor menu follows the newly active session.
|
||||
- **GIVEN** Kandev runs on Linux or macOS and the active session uses Local or Worktree, **WHEN** a
|
||||
user opens the editor menu, **THEN** **VS Code (Embedded)** is available.
|
||||
- **GIVEN** the saved default is **VS Code (Embedded)** but the active session does not support it,
|
||||
**WHEN** the primary editor action is activated, **THEN** Kandev opens the first compatible
|
||||
fallback without changing the saved default.
|
||||
- **GIVEN** the active session executor is missing or unrecognized, **WHEN** task details render,
|
||||
**THEN** the embedded editor is absent and cannot be opened through the API.
|
||||
- **GIVEN** an unsupported session, **WHEN** a client directly requests `internal_vscode`,
|
||||
**THEN** the backend returns the editor-unavailable conflict response.
|
||||
- **GIVEN** a visitor's browser reports Windows but the active task runtime is supported, **WHEN**
|
||||
the editor menu opens, **THEN** **VS Code (Embedded)** remains available.
|
||||
- **GIVEN** a phone user opens task details for any executor, **WHEN** the mobile topbar renders,
|
||||
**THEN** no desktop editor action is exposed and the header does not overflow horizontally.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing code-server with Microsoft VS Code Server.
|
||||
- Adding or packaging a native Windows code-server runtime.
|
||||
- Changing Kandev's pinned code-server version or installation source.
|
||||
- Adding Windows as a supported SSH execution platform.
|
||||
- Proactively hiding the embedded editor from editor settings, file-level **Open with** menus,
|
||||
layout presets, or the Dockview add-panel menu. The backend availability guard still applies to
|
||||
open-editor API requests.
|
||||
- Changing external editor discovery, custom editor commands, or hosted-editor behavior.
|
||||
- Adding an editor action to the phone task topbar.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Embedded VS Code executor availability plan](../../plans/embedded-vscode-executor-availability/plan.md)
|
||||
@@ -1,11 +1,16 @@
|
||||
---
|
||||
status: shipped
|
||||
status: archived
|
||||
created: 2026-07-29
|
||||
owner: Kandev
|
||||
superseded_by: embedded-vscode-executor-availability
|
||||
---
|
||||
|
||||
# Embedded VS Code Windows Availability
|
||||
|
||||
> Archived on 2026-07-30. This host-wide rule is superseded by
|
||||
> [Embedded VS Code Executor Availability](embedded-vscode-executor-availability.md), which derives
|
||||
> availability from the active session's executor runtime.
|
||||
|
||||
## Why
|
||||
|
||||
Kandev installs **VS Code (Embedded)** from code-server standalone release archives, while
|
||||
|
||||
Reference in New Issue
Block a user