diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index ad6feb214b..d89c4e701c 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -71,6 +71,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, SessionID: req.SessionID, + TaskEnvironmentID: req.TaskEnvironmentID, TaskTitle: req.TaskTitle, AgentProfileID: req.AgentProfileID, WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 4cc83b7a3b..d53454cd1b 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -34,6 +34,13 @@ func NewShellHandlers(lifecycleMgr *lifecycle.Manager, scriptService scripts.Scr } } +func (h *ShellHandlers) resolveScopeID(ctx context.Context, sessionID string) (string, error) { + if _, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID); err != nil { + return "", fmt.Errorf("failed to ensure execution for scope: %w", err) + } + return h.lifecycleMgr.ResolveScopeKey(sessionID), nil +} + // RegisterHandlers registers shell handlers with the WebSocket dispatcher func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionShellStatus, h.wsShellStatus) @@ -204,7 +211,6 @@ func (h *ShellHandlers) wsUserShellList(ctx context.Context, msg *ws.Message) (* return nil, fmt.Errorf("session_id is required") } - // Get the interactive runner interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() if interactiveRunner == nil { return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ @@ -212,11 +218,15 @@ func (h *ShellHandlers) wsUserShellList(ctx context.Context, msg *ws.Message) (* }) } - // Get list of user shells - shells := interactiveRunner.ListUserShells(req.SessionID) + scopeID, err := h.resolveScopeID(ctx, req.SessionID) + if err != nil { + return nil, err + } + shells := interactiveRunner.ListUserShells(scopeID) h.logger.Debug("listing user shells", zap.String("session_id", req.SessionID), + zap.String("scope_id", scopeID), zap.Int("count", len(shells))) return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ @@ -255,11 +265,17 @@ func (h *ShellHandlers) wsUserShellCreate(ctx context.Context, msg *ws.Message) return nil, err } + scopeID, err := h.resolveScopeID(ctx, req.SessionID) + if err != nil { + return nil, err + } + if command != "" { terminalID := "script-" + uuid.New().String() - interactiveRunner.RegisterScriptShell(req.SessionID, terminalID, label, command) + interactiveRunner.RegisterScriptShell(scopeID, terminalID, label, command) h.logger.Info("created script terminal", zap.String("session_id", req.SessionID), + zap.String("scope_id", scopeID), zap.String("terminal_id", terminalID), zap.String("label", label), zap.String("initial_command", command)) @@ -271,9 +287,10 @@ func (h *ShellHandlers) wsUserShellCreate(ctx context.Context, msg *ws.Message) }) } - result := interactiveRunner.CreateUserShell(req.SessionID) + result := interactiveRunner.CreateUserShell(scopeID) h.logger.Info("created user shell", zap.String("session_id", req.SessionID), + zap.String("scope_id", scopeID), zap.String("terminal_id", result.TerminalID), zap.String("label", result.Label), zap.Bool("closable", result.Closable)) @@ -338,8 +355,11 @@ func (h *ShellHandlers) wsUserShellStop(ctx context.Context, msg *ws.Message) (* return nil, fmt.Errorf("interactive runner not available") } - // Stop the user shell - if err := interactiveRunner.StopUserShell(ctx, req.SessionID, req.TerminalID); err != nil { + scopeID, err := h.resolveScopeID(ctx, req.SessionID) + if err != nil { + return nil, err + } + if err := interactiveRunner.StopUserShell(ctx, scopeID, req.TerminalID); err != nil { h.logger.Warn("failed to stop user shell", zap.String("session_id", req.SessionID), zap.String("terminal_id", req.TerminalID), @@ -379,11 +399,16 @@ func (h *ShellHandlers) httpListTerminals(c *gin.Context) { return } - // Get list of user shells - shells := interactiveRunner.ListUserShells(sessionID) + scopeID, err := h.resolveScopeID(c.Request.Context(), sessionID) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + shells := interactiveRunner.ListUserShells(scopeID) h.logger.Debug("listing terminals via HTTP", zap.String("session_id", sessionID), + zap.String("scope_id", scopeID), zap.Int("count", len(shells))) // Transform to response format diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 2796755365..861871f340 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -169,6 +169,7 @@ type ExecutorCreateRequest struct { InstanceID string TaskID string SessionID string + TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task) AgentProfileID string WorkspacePath string Protocol string @@ -246,6 +247,7 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE ID: ri.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, + TaskEnvironmentID: req.TaskEnvironmentID, AgentProfileID: req.AgentProfileID, AgentID: agentID, ContainerID: ri.ContainerID, diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 700835e38e..fd43f63a79 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -283,6 +283,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work InstanceID: executionID, TaskID: taskID, SessionID: info.SessionID, + TaskEnvironmentID: info.TaskEnvironmentID, AgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), diff --git a/apps/backend/internal/agent/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/lifecycle/manager_execution_test.go index 591e758a0d..6318b3c97c 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution_test.go @@ -38,6 +38,64 @@ func TestErrSessionWorkspaceNotReady_UnrelatedError(t *testing.T) { } } +func TestResolveScopeKey(t *testing.T) { + t.Run("returns TaskEnvironmentID when execution carries it", func(t *testing.T) { + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-1", + SessionID: "session-A", + TaskID: "task-1", + TaskEnvironmentID: "env-1", + Status: v1.AgentStatusRunning, + }) + mgr := &Manager{executionStore: store, logger: newTestLogger()} + + if got := mgr.ResolveScopeKey("session-A"); got != "env-1" { + t.Errorf("ResolveScopeKey = %q, want %q", got, "env-1") + } + }) + + t.Run("falls back to sessionID when no execution", func(t *testing.T) { + mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger()} + + if got := mgr.ResolveScopeKey("session-X"); got != "session-X" { + t.Errorf("ResolveScopeKey fallback = %q, want %q", got, "session-X") + } + }) + + t.Run("falls back to sessionID when execution has empty env", func(t *testing.T) { + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-2", + SessionID: "session-B", + TaskID: "task-2", + Status: v1.AgentStatusRunning, + }) + mgr := &Manager{executionStore: store, logger: newTestLogger()} + + if got := mgr.ResolveScopeKey("session-B"); got != "session-B" { + t.Errorf("ResolveScopeKey legacy = %q, want %q", got, "session-B") + } + }) + + t.Run("two sessions sharing env resolve to the same scope", func(t *testing.T) { + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-A", SessionID: "sess-A", TaskID: "task-1", + TaskEnvironmentID: "env-shared", Status: v1.AgentStatusRunning, + }) + store.Add(&AgentExecution{ + ID: "exec-B", SessionID: "sess-B", TaskID: "task-1", + TaskEnvironmentID: "env-shared", Status: v1.AgentStatusRunning, + }) + mgr := &Manager{executionStore: store, logger: newTestLogger()} + + if mgr.ResolveScopeKey("sess-A") != mgr.ResolveScopeKey("sess-B") { + t.Error("sessions in the same env must resolve to the same scope key") + } + }) +} + func TestGetOrEnsureExecution(t *testing.T) { t.Run("returns existing execution from store", func(t *testing.T) { store := NewExecutionStore() diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 5a4180e3f8..16b1a76448 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -634,6 +634,21 @@ func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bo return m.executionStore.GetBySessionID(sessionID) } +// ResolveScopeKey returns a stable scope key for env-keyed resources (user +// shells, etc.) given a sessionID. Returns the session's TaskEnvironmentID +// when an active execution carries it, otherwise falls back to the sessionID +// itself so callers always get a non-empty key. +// +// This is the seam that makes terminals task-keyed: two sessions in the +// same task share an env, so they resolve to the same scope key and the +// runner returns one shared shell list instead of duplicating per-session. +func (m *Manager) ResolveScopeKey(sessionID string) string { + if exec, ok := m.executionStore.GetBySessionID(sessionID); ok && exec.TaskEnvironmentID != "" { + return exec.TaskEnvironmentID + } + return sessionID +} + // IsRemoteSession checks whether a session is associated with a remote executor // (e.g., sprites). It first checks the in-memory execution store, then falls back // to the database via WorkspaceInfoProvider. This is useful when the execution diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 966f68cedc..d76eb09133 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -307,6 +307,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st InstanceID: executionID, TaskID: reqWithWorktree.TaskID, SessionID: reqWithWorktree.SessionID, + TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID, AgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 778c5292f3..de0f1dc18e 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -23,24 +23,25 @@ const AgentCtlPort = ports.AgentCtl // AgentExecution represents a running agent execution type AgentExecution struct { - ID string - TaskID string - SessionID string - AgentProfileID string - AgentID string // Agent type ID (e.g., "claude-acp", "codex") — used for fallback auth methods - ContainerID string - ContainerIP string // IP address of the container for agentctl communication - WorkspacePath string // Path to the workspace (worktree or repository path) - ACPSessionID string // ACP session ID to resume, if available - AgentCommand string // Command to start the agent subprocess - ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp) - RuntimeName string // Name of the runtime used (e.g., "docker", "standalone") - Status v1.AgentStatus - StartedAt time.Time - FinishedAt *time.Time - ExitCode *int - ErrorMessage string - Metadata map[string]interface{} + ID string + TaskID string + SessionID string + TaskEnvironmentID string // Env owning this execution; sessions in the same task share one env + AgentProfileID string + AgentID string // Agent type ID (e.g., "claude-acp", "codex") — used for fallback auth methods + ContainerID string + ContainerIP string // IP address of the container for agentctl communication + WorkspacePath string // Path to the workspace (worktree or repository path) + ACPSessionID string // ACP session ID to resume, if available + AgentCommand string // Command to start the agent subprocess + ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp) + RuntimeName string // Name of the runtime used (e.g., "docker", "standalone") + Status v1.AgentStatus + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + ErrorMessage string + Metadata map[string]interface{} // PrepareResult carries the environment preparation result back to the caller // so it can be persisted synchronously before UpdateTaskSession clobbers metadata. @@ -251,17 +252,18 @@ func (ae *AgentExecution) EndSessionSpan() { // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { - TaskID string - SessionID string - TaskTitle string // Human-readable task title for semantic worktree naming - AgentProfileID string - WorkspacePath string // Host path to workspace (original repository path) - TaskDescription string // Task description to send via ACP prompt - Attachments []MessageAttachment // Attachments (images/files) for the initial prompt - Env map[string]string // Additional env vars - ACPSessionID string // ACP session ID to resume, if available - Metadata map[string]interface{} - ModelOverride string // If set, use this model instead of the profile's model + TaskID string + SessionID string + TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) + TaskTitle string // Human-readable task title for semantic worktree naming + AgentProfileID string + WorkspacePath string // Host path to workspace (original repository path) + TaskDescription string // Task description to send via ACP prompt + Attachments []MessageAttachment // Attachments (images/files) for the initial prompt + Env map[string]string // Additional env vars + ACPSessionID string // ACP session ID to resume, if available + Metadata map[string]interface{} + ModelOverride string // If set, use this model instead of the profile's model // Ephemeral tasks (quick chat) get fallback workspace directories when no repo is configured. // Non-ephemeral tasks without a workspace path will not receive a fallback directory. @@ -354,12 +356,13 @@ type McpConfigProvider interface { // WorkspaceInfo contains information about a task's workspace for on-demand execution creation type WorkspaceInfo struct { - TaskID string - SessionID string // Task session ID (from task_sessions table) - WorkspacePath string // Path to the workspace/repository - AgentProfileID string // Optional - agent profile for the task - AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation - ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) + TaskID string + SessionID string // Task session ID (from task_sessions table) + TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) + WorkspacePath string // Path to the workspace/repository + AgentProfileID string // Optional - agent profile for the task + AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation + ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) // Executor-aware fields for correct runtime selection and remote reconnection ExecutorType string // Executor type (e.g., "local_pc", "sprites") diff --git a/apps/backend/internal/agentctl/server/api/handshake_integration_test.go b/apps/backend/internal/agentctl/server/api/handshake_integration_test.go index c2841a085d..40a46f8f1f 100644 --- a/apps/backend/internal/agentctl/server/api/handshake_integration_test.go +++ b/apps/backend/internal/agentctl/server/api/handshake_integration_test.go @@ -9,6 +9,7 @@ import ( agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/instance" "github.com/kandev/kandev/internal/common/logger" ) @@ -22,7 +23,7 @@ func TestHandshakeIntegration_FullFlow(t *testing.T) { BootstrapNonce: "integration-test-nonce-abc123", } log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) - cs := NewControlServer(cfg, nil, log) + cs := NewControlServer(cfg, &instance.Manager{}, log) server := httptest.NewServer(cs.Router()) defer server.Close() @@ -56,8 +57,8 @@ func TestHandshakeIntegration_FullFlow(t *testing.T) { } // 6. After handshake, authenticated requests should WORK - // (ListInstances will return an error because instMgr is nil, - // but it should NOT be a 401 — it should get past auth) + // (ListInstances may return an endpoint/client error, but it should + // NOT be a 401 — it should get past auth.) _, err = client.ListInstances(ctx) if err != nil { // If the error mentions "unauthorized" or "auth", auth is still failing @@ -89,7 +90,7 @@ func TestHandshakeIntegration_StandaloneMode(t *testing.T) { // No BootstrapNonce — standalone mode } log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) - cs := NewControlServer(cfg, nil, log) + cs := NewControlServer(cfg, &instance.Manager{}, log) server := httptest.NewServer(cs.Router()) defer server.Close() @@ -141,5 +142,5 @@ func parseHostPort(t *testing.T, url string) (string, int) { func containsAuthError(s string) bool { lower := strings.ToLower(s) - return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "401") + return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "status 401") } diff --git a/apps/backend/internal/agentctl/server/process/interactive_shells.go b/apps/backend/internal/agentctl/server/process/interactive_shells.go index b4891f6d84..fdf17ac392 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_shells.go +++ b/apps/backend/internal/agentctl/server/process/interactive_shells.go @@ -38,42 +38,42 @@ type UserShellInfo struct { } // CreateUserShell creates a new user shell terminal with auto-assigned ID and label. -// The first shell for a session is labeled "Terminal" and is not closable. +// The first shell for a scope is labeled "Terminal" and is not closable. // Subsequent shells are labeled "Terminal 2", "Terminal 3", etc. and are closable. // The entry is registered atomically to prevent races with ListUserShells. -func (r *InteractiveRunner) CreateUserShell(sessionID string) CreateUserShellResult { +// +// `scopeID` groups shells. Callers should pass `taskEnvironmentID` so sessions +// in the same task share one shell list. Falls back to sessionID is preserved +// at the caller boundary (lifecycle.Manager.ResolveScopeKey). +func (r *InteractiveRunner) CreateUserShell(scopeID string) CreateUserShellResult { r.userShellsMu.Lock() defer r.userShellsMu.Unlock() - // Count existing plain shell terminals for this session - prefix := sessionID + ":" + prefix := scopeID + ":" shellCount := 0 for key, entry := range r.userShells { if strings.HasPrefix(key, prefix) { terminalID := key[len(prefix):] - // Only count plain shells (not script terminals) if strings.HasPrefix(terminalID, "shell-") && entry.InitialCommand == "" { shellCount++ } } } - // Generate terminal ID and label terminalID := "shell-" + uuid.New().String() var label string var closable bool if shellCount == 0 { label = "Terminal" - closable = false // First terminal is not closable + closable = false } else { label = fmt.Sprintf("Terminal %d", shellCount+1) closable = true } - // Register the entry so ListUserShells includes it immediately r.userShells[prefix+terminalID] = &userShellEntry{ - ProcessID: "", // No process yet - will be started when WebSocket connects + ProcessID: "", Label: label, InitialCommand: "", Closable: closable, @@ -89,8 +89,8 @@ func (r *InteractiveRunner) CreateUserShell(sessionID string) CreateUserShellRes // RegisterScriptShell registers a script terminal entry so ListUserShells returns it. // The actual process is not started until the WebSocket connects (StartUserShell handles that). -func (r *InteractiveRunner) RegisterScriptShell(sessionID, terminalID, label, initialCommand string) { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) RegisterScriptShell(scopeID, terminalID, label, initialCommand string) { + key := scopeID + ":" + terminalID r.userShellsMu.Lock() defer r.userShellsMu.Unlock() @@ -108,8 +108,8 @@ func (r *InteractiveRunner) RegisterScriptShell(sessionID, terminalID, label, in // shell entry, or "" when no entry exists. Used by remote-shell handlers to recover // the script command across the WS-handshake boundary, since the per-terminal WS URL // only carries terminalId — not script_id or command. -func (r *InteractiveRunner) LookupShellInitialCommand(sessionID, terminalID string) string { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) LookupShellInitialCommand(scopeID, terminalID string) string { + key := scopeID + ":" + terminalID r.userShellsMu.RLock() defer r.userShellsMu.RUnlock() if entry, ok := r.userShells[key]; ok { @@ -119,12 +119,15 @@ func (r *InteractiveRunner) LookupShellInitialCommand(sessionID, terminalID stri } // StartUserShell starts or returns an existing user shell for a terminal tab. -// Each terminal tab gets its own independent shell process. -// If opts.InitialCommand is provided, it will be written to stdin after the shell starts. -func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, terminalID, workingDir, preferredShell string, opts *UserShellOptions) (*InteractiveProcessInfo, error) { - key := sessionID + ":" + terminalID +// Each terminal tab gets its own independent shell process. The shell is keyed +// in the userShells map by `scopeID` (taskEnvironmentID when known) so that +// sessions sharing an env share the same shell. The underlying process is +// still started under `processSessionID` for InteractiveStartRequest purposes +// (ring-buffer accounting, etc.) but `IsUserShell: true` excludes it from +// session-level routing. +func (r *InteractiveRunner) StartUserShell(ctx context.Context, scopeID, processSessionID, terminalID, workingDir, preferredShell string, opts *UserShellOptions) (*InteractiveProcessInfo, error) { + key := scopeID + ":" + terminalID - // Normalize options if opts == nil { opts = &UserShellOptions{} } @@ -132,38 +135,32 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi opts.Label = "Terminal" } - // Check if shell entry already exists (auto-created by ListUserShells or RegisterScriptShell) var existingEntry *userShellEntry r.userShellsMu.RLock() entry, exists := r.userShells[key] if exists { - // If entry has a process, check if it's still alive if entry.ProcessID != "" { if info, ok := r.Get(entry.ProcessID, false); ok { r.userShellsMu.RUnlock() return info, nil } - // Process died - we'll start a new one below } - // Entry exists but no process (pre-registered) or process died - // Keep the existing metadata (label, closable, createdAt, initialCommand) existingEntry = entry } r.userShellsMu.RUnlock() - // Use initial command from pre-registered entry if not provided in opts initialCommand := opts.InitialCommand if initialCommand == "" && existingEntry != nil { initialCommand = existingEntry.InitialCommand } req := InteractiveStartRequest{ - SessionID: sessionID, + SessionID: processSessionID, Command: defaultShellCommand(preferredShell), WorkingDir: workingDir, InitialCommand: initialCommand, - DisableTurnDetection: true, // User shells must not trigger turn complete / MarkReady - IsUserShell: true, // Exclude from session-level lookups (ResizeBySession, GetPtyWriterBySession) + DisableTurnDetection: true, + IsUserShell: true, } info, err := r.Start(ctx, req) @@ -171,16 +168,12 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi return nil, err } - // Track the user shell with metadata - // If entry already exists (auto-created by ListUserShells), preserve its metadata r.userShellsMu.Lock() if existingEntry != nil { - // Update existing entry with the new process ID existingEntry.ProcessID = info.ID r.userShells[key] = existingEntry } else { - // Create new entry - closable := true // Default: closable + closable := true if opts.Closable != nil { closable = *opts.Closable } @@ -195,7 +188,8 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi r.userShellsMu.Unlock() r.logger.Info("started user shell", - zap.String("session_id", sessionID), + zap.String("scope_id", scopeID), + zap.String("session_id", processSessionID), zap.String("terminal_id", terminalID), zap.String("process_id", info.ID), zap.String("shell", req.Command[0]), @@ -206,13 +200,13 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi return info, nil } -// ListUserShells returns all user shells for a session, sorted by creation time. +// ListUserShells returns all user shells for a scope, sorted by creation time. // If no plain shell terminals exist, automatically creates the first "Terminal" entry. -func (r *InteractiveRunner) ListUserShells(sessionID string) []UserShellInfo { +func (r *InteractiveRunner) ListUserShells(scopeID string) []UserShellInfo { r.userShellsMu.Lock() defer r.userShellsMu.Unlock() - prefix := sessionID + ":" + prefix := scopeID + ":" var shells []UserShellInfo hasPlainShell := false @@ -270,8 +264,8 @@ func (r *InteractiveRunner) ListUserShells(sessionID string) []UserShellInfo { } // StopUserShell stops a user shell for a terminal tab. -func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, terminalID string) error { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) StopUserShell(ctx context.Context, scopeID, terminalID string) error { + key := scopeID + ":" + terminalID r.userShellsMu.Lock() entry, exists := r.userShells[key] @@ -285,7 +279,7 @@ func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, termin } r.logger.Info("stopping user shell", - zap.String("session_id", sessionID), + zap.String("scope_id", scopeID), zap.String("terminal_id", terminalID), zap.String("process_id", entry.ProcessID)) @@ -293,15 +287,15 @@ func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, termin } // ResizeUserShell resizes the PTY for a user shell. -func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, rows uint16) error { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) ResizeUserShell(scopeID, terminalID string, cols, rows uint16) error { + key := scopeID + ":" + terminalID r.userShellsMu.RLock() entry, exists := r.userShells[key] r.userShellsMu.RUnlock() if !exists { - return fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) + return fmt.Errorf("no user shell found for scope %s terminal %s", scopeID, terminalID) } proc, ok := r.get(entry.ProcessID) @@ -310,21 +304,21 @@ func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, } return r.lazyStartAndResize(proc, cols, rows, - zap.String("session_id", sessionID), + zap.String("scope_id", scopeID), zap.String("terminal_id", terminalID), ) } // GetUserShellPtyWriter returns the PTY writer for a user shell. -func (r *InteractiveRunner) GetUserShellPtyWriter(sessionID, terminalID string) (io.Writer, string, error) { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) GetUserShellPtyWriter(scopeID, terminalID string) (io.Writer, string, error) { + key := scopeID + ":" + terminalID r.userShellsMu.RLock() entry, exists := r.userShells[key] r.userShellsMu.RUnlock() if !exists { - return nil, "", fmt.Errorf("no user shell found for session %s terminal %s", sessionID, terminalID) + return nil, "", fmt.Errorf("no user shell found for scope %s terminal %s", scopeID, terminalID) } writer, err := r.GetPtyWriter(entry.ProcessID) @@ -336,8 +330,8 @@ func (r *InteractiveRunner) GetUserShellPtyWriter(sessionID, terminalID string) } // ClearUserShellDirectOutput clears the direct output for a user shell. -func (r *InteractiveRunner) ClearUserShellDirectOutput(sessionID, terminalID string) { - key := sessionID + ":" + terminalID +func (r *InteractiveRunner) ClearUserShellDirectOutput(scopeID, terminalID string) { + key := scopeID + ":" + terminalID r.userShellsMu.RLock() entry, exists := r.userShells[key] @@ -358,6 +352,6 @@ func (r *InteractiveRunner) ClearUserShellDirectOutput(sessionID, terminalID str proc.directOutputMu.Unlock() r.logger.Info("direct output cleared for user shell", - zap.String("session_id", sessionID), + zap.String("scope_id", scopeID), zap.String("terminal_id", terminalID)) } diff --git a/apps/backend/internal/agentctl/server/process/runner_shells_test.go b/apps/backend/internal/agentctl/server/process/runner_shells_test.go index 00c55324db..5933cd9c11 100644 --- a/apps/backend/internal/agentctl/server/process/runner_shells_test.go +++ b/apps/backend/internal/agentctl/server/process/runner_shells_test.go @@ -144,7 +144,7 @@ func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) { r1 := runner.CreateUserShell("session-1") r2 := runner.CreateUserShell("session-2") - // Both should be "Terminal" (first in each session) + // Both should be "Terminal" (first in each scope) if r1.Label != "Terminal" { t.Errorf("session-1 Label = %q, want 'Terminal'", r1.Label) } @@ -152,7 +152,48 @@ func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) { t.Errorf("session-2 Label = %q, want 'Terminal'", r2.Label) } if r1.Closable || r2.Closable { - t.Error("first terminal in each session should not be closable") + t.Error("first terminal in each scope should not be closable") + } +} + +// Two sessions in the same task share a TaskEnvironmentID, and callers pass +// that env as the scopeID. The runner must return one shared shell list for +// them — that's the whole reason terminals are env-keyed. +func TestInteractiveRunner_SharedScope_AcrossSessions(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + envID := "env-shared" + + first := runner.CreateUserShell(envID) + second := runner.CreateUserShell(envID) + + // Subsequent shell in the same scope must increment. + if first.Label != "Terminal" { + t.Errorf("first Label = %q, want 'Terminal'", first.Label) + } + if second.Label != "Terminal 2" { + t.Errorf("second Label = %q, want 'Terminal 2' (same scope, incremented)", second.Label) + } + + // Both terminals must show up when any session in that env lists shells. + shells := runner.ListUserShells(envID) + if len(shells) != 2 { + t.Fatalf("ListUserShells(envID) returned %d, want 2", len(shells)) + } + ids := map[string]bool{} + for _, s := range shells { + ids[s.TerminalID] = true + } + if !ids[first.TerminalID] || !ids[second.TerminalID] { + t.Error("ListUserShells did not include both shells created under the shared scope") + } + + // A different env must remain isolated. + otherShells := runner.ListUserShells("env-other") + for _, s := range otherShells { + if s.TerminalID == first.TerminalID || s.TerminalID == second.TerminalID { + t.Error("shells leaked across envs — scope isolation broken") + } } } diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index 5e2118a70f..f962625340 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -283,7 +283,8 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter // scripts and dev_script) would open empty in containerized sessions. if initialCommand == "" { if runner := h.lifecycleMgr.GetInteractiveRunner(); runner != nil { - initialCommand = runner.LookupShellInitialCommand(sessionID, terminalID) + scopeID := scopeIDForExecution(execution, sessionID) + initialCommand = runner.LookupShellInitialCommand(scopeID, terminalID) } } @@ -876,6 +877,20 @@ func (h *TerminalHandler) resolveWorkingDir(ctx context.Context, sessionID strin return execution.WorkspacePath, nil } +func (h *TerminalHandler) resolveScopeID(ctx context.Context, sessionID string) (string, error) { + if _, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID); err != nil { + return "", fmt.Errorf("failed to ensure execution for scope: %w", err) + } + return h.lifecycleMgr.ResolveScopeKey(sessionID), nil +} + +func scopeIDForExecution(execution *lifecycle.AgentExecution, sessionID string) string { + if execution != nil && execution.TaskEnvironmentID != "" { + return execution.TaskEnvironmentID + } + return sessionID +} + // resolveShellLabel returns the label and initial command for a user shell, derived // from either a script ID lookup or a plain label query parameter. // Returns an HTTP error string (non-empty) when the script lookup fails. @@ -903,10 +918,10 @@ func (h *TerminalHandler) startUserShellProcess( sessionID string, terminalID string, interactiveRunner *process.InteractiveRunner, -) (processID string, httpStatus int, errMsg string) { +) (processID string, scopeID string, httpStatus int, errMsg string) { label, initialCommand, httpErr := h.resolveShellLabel(c) if httpErr != "" { - return "", http.StatusBadRequest, httpErr + return "", "", http.StatusBadRequest, httpErr } h.logger.Info("handleUserShellWS: starting user shell handling", @@ -915,13 +930,21 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("label", label), zap.String("initial_command", initialCommand)) + scopeID, err := h.resolveScopeID(c.Request.Context(), sessionID) + if err != nil { + h.logger.Warn("handleUserShellWS: scope not ready", + zap.String("session_id", sessionID), + zap.Error(err)) + return "", "", http.StatusServiceUnavailable, err.Error() + } + preferredShell := h.resolvePreferredShell(c.Request.Context()) workingDir, err := h.resolveWorkingDir(c.Request.Context(), sessionID) if err != nil { h.logger.Warn("handleUserShellWS: workspace path not ready", zap.String("session_id", sessionID), zap.Error(err)) - return "", http.StatusServiceUnavailable, err.Error() + return "", "", http.StatusServiceUnavailable, err.Error() } opts := &process.UserShellOptions{Label: label, InitialCommand: initialCommand} @@ -935,14 +958,14 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("initial_command", opts.InitialCommand)) info, err := interactiveRunner.StartUserShell( - c.Request.Context(), sessionID, terminalID, workingDir, preferredShell, opts, + c.Request.Context(), scopeID, sessionID, terminalID, workingDir, preferredShell, opts, ) if err != nil { h.logger.Error("failed to start user shell", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.Error(err)) - return "", http.StatusServiceUnavailable, err.Error() + return "", "", http.StatusServiceUnavailable, err.Error() } h.logger.Info("handleUserShellWS: user shell started successfully", @@ -950,7 +973,7 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("terminal_id", terminalID), zap.String("process_id", info.ID)) - return info.ID, 0, "" + return info.ID, scopeID, 0, "" } // handleUserShellWS handles WebSocket connections for user shell terminals. @@ -961,7 +984,7 @@ func (h *TerminalHandler) handleUserShellWS( terminalID string, interactiveRunner *process.InteractiveRunner, ) { - processID, httpStatus, errMsg := h.startUserShellProcess(c, sessionID, terminalID, interactiveRunner) + processID, scopeID, httpStatus, errMsg := h.startUserShellProcess(c, sessionID, terminalID, interactiveRunner) if errMsg != "" { c.JSON(httpStatus, gin.H{"error": errMsg}) return @@ -982,13 +1005,14 @@ func (h *TerminalHandler) handleUserShellWS( zap.String("process_id", processID)) wsw := newWsWriter(conn) - h.runUserShellBridge(conn, sessionID, terminalID, processID, interactiveRunner, wsw) + h.runUserShellBridge(conn, sessionID, scopeID, terminalID, processID, interactiveRunner, wsw) } // runUserShellBridge handles WebSocket I/O for user shell terminals. func (h *TerminalHandler) runUserShellBridge( conn *gorillaws.Conn, sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1001,7 +1025,7 @@ func (h *TerminalHandler) runUserShellBridge( // Clean up WebSocket resources but DON'T stop the process // The process should only be stopped via explicit user_shell.stop message from frontend // This allows reconnection after React remounts or temporary disconnects - interactiveRunner.ClearUserShellDirectOutput(sessionID, terminalID) + interactiveRunner.ClearUserShellDirectOutput(scopeID, terminalID) _ = wsw.Close() _ = conn.Close() @@ -1034,14 +1058,14 @@ func (h *TerminalHandler) runUserShellBridge( if isResizeCommand(data) { directOutputSet = h.handleUserShellResizeCommand( - data[1:], sessionID, terminalID, processID, + data[1:], sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, directOutputSet, &ptyWriter, ) continue } directOutputSet = h.handleUserShellInput( - data, sessionID, terminalID, processID, + data, sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, &ptyWriter, directOutputSet, ) } @@ -1052,6 +1076,7 @@ func (h *TerminalHandler) runUserShellBridge( func (h *TerminalHandler) handleUserShellResizeCommand( payload []byte, sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1061,11 +1086,11 @@ func (h *TerminalHandler) handleUserShellResizeCommand( ) bool { // Set up PTY access BEFORE handling resize if not already done if !directOutputSet { - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + if h.setupUserShellPtyAccess(sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { directOutputSet = true } } - h.handleUserShellResize(payload, sessionID, terminalID, interactiveRunner) + h.handleUserShellResize(payload, sessionID, scopeID, terminalID, interactiveRunner) return directOutputSet } @@ -1074,6 +1099,7 @@ func (h *TerminalHandler) handleUserShellResizeCommand( func (h *TerminalHandler) handleUserShellInput( data []byte, sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1082,9 +1108,9 @@ func (h *TerminalHandler) handleUserShellInput( directOutputSet bool, ) bool { if *ptyWriter != nil { - return h.writeToReadyUserShellPty(data, sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) + return h.writeToReadyUserShellPty(data, sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) } - return h.writeToUnreadyUserShellPty(data, sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) + return h.writeToUnreadyUserShellPty(data, sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, ptyWriter, directOutputSet) } // writeToReadyUserShellPty writes input to an established user shell PTY. @@ -1092,6 +1118,7 @@ func (h *TerminalHandler) handleUserShellInput( func (h *TerminalHandler) writeToReadyUserShellPty( data []byte, sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1105,7 +1132,7 @@ func (h *TerminalHandler) writeToReadyUserShellPty( zap.String("terminal_id", terminalID), zap.Error(err)) // Try to re-establish PTY access - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + if h.setupUserShellPtyAccess(sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { directOutputSet = true if _, err := (*ptyWriter).Write(data); err != nil { h.logger.Debug("PTY write error after reconnect", @@ -1123,6 +1150,7 @@ func (h *TerminalHandler) writeToReadyUserShellPty( func (h *TerminalHandler) writeToUnreadyUserShellPty( data []byte, sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1130,7 +1158,7 @@ func (h *TerminalHandler) writeToUnreadyUserShellPty( ptyWriter *io.Writer, directOutputSet bool, ) bool { - if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { + if h.setupUserShellPtyAccess(sessionID, scopeID, terminalID, processID, interactiveRunner, wsw, ptyWriter) { directOutputSet = true if _, err := (*ptyWriter).Write(data); err != nil { h.logger.Debug("PTY write error after setup", @@ -1150,6 +1178,7 @@ func (h *TerminalHandler) writeToUnreadyUserShellPty( // setupUserShellPtyAccess sets up PTY access for a user shell terminal. func (h *TerminalHandler) setupUserShellPtyAccess( sessionID string, + scopeID string, terminalID string, processID string, interactiveRunner *process.InteractiveRunner, @@ -1157,7 +1186,7 @@ func (h *TerminalHandler) setupUserShellPtyAccess( ptyWriter *io.Writer, ) bool { getWriter := func() (io.Writer, error) { - writer, _, err := interactiveRunner.GetUserShellPtyWriter(sessionID, terminalID) + writer, _, err := interactiveRunner.GetUserShellPtyWriter(scopeID, terminalID) return writer, err } return h.setupPtyAccessCommon(sessionID, processID, interactiveRunner, wsw, ptyWriter, getWriter) @@ -1248,6 +1277,7 @@ func (h *TerminalHandler) setupPtyAccessCommon( func (h *TerminalHandler) handleUserShellResize( data []byte, sessionID string, + scopeID string, terminalID string, interactiveRunner *process.InteractiveRunner, ) { @@ -1269,8 +1299,7 @@ func (h *TerminalHandler) handleUserShellResize( return } - // Resize the user shell PTY - if err := interactiveRunner.ResizeUserShell(sessionID, terminalID, resize.Cols, resize.Rows); err != nil { + if err := interactiveRunner.ResizeUserShell(scopeID, terminalID, resize.Cols, resize.Rows); err != nil { h.logger.Warn("failed to resize user shell PTY", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 2ffc4b364d..3dd2ee38b1 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -223,6 +223,7 @@ type AgentProfileInfo struct { type LaunchAgentRequest struct { TaskID string SessionID string + TaskEnvironmentID string // Env owning this session (shared across sessions in the same task) TaskTitle string // Human-readable task title for semantic worktree naming AgentProfileID string RepositoryURL string diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 62a92b918b..144b367adb 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -375,6 +375,12 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, err } + // Resolve the env ID before LaunchAgent so the in-memory AgentExecution + // is env-scoped from the first shell/layout request, not only after DB + // persistence succeeds. + existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) + assignLaunchTaskEnvironmentID(session, existingEnv) + req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, repoInfo) if err != nil { return nil, err @@ -391,7 +397,6 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses } // Check for an existing task environment to reuse its worktree - existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) if existingEnv != nil && existingEnv.WorktreeID != "" && req.UseWorktree { req.WorktreeID = existingEnv.WorktreeID e.logger.Info("reusing existing task environment worktree", @@ -492,6 +497,16 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m return execution, nil } +func assignLaunchTaskEnvironmentID(session *models.TaskSession, existingEnv *models.TaskEnvironment) { + if existingEnv != nil && existingEnv.ID != "" { + session.TaskEnvironmentID = existingEnv.ID + return + } + if session.TaskEnvironmentID == "" { + session.TaskEnvironmentID = uuid.New().String() + } +} + // buildLaunchAgentRequest constructs a LaunchAgentRequest for a new session launch, // applying executor config, repository/worktree settings, and remote docker URL as needed. func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, executorID, prompt string, repoInfo *repoInfo) (*LaunchAgentRequest, executorConfig, error) { @@ -504,13 +519,14 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s } sessionID := session.ID req := &LaunchAgentRequest{ - TaskID: task.ID, - TaskTitle: task.Title, - AgentProfileID: agentProfileID, - TaskDescription: prompt, - Priority: task.Priority, - SessionID: sessionID, - IsEphemeral: task.IsEphemeral, + TaskID: task.ID, + TaskTitle: task.Title, + AgentProfileID: agentProfileID, + TaskDescription: prompt, + Priority: task.Priority, + SessionID: sessionID, + TaskEnvironmentID: session.TaskEnvironmentID, + IsEphemeral: task.IsEphemeral, } execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) @@ -835,6 +851,7 @@ func (e *Executor) persistTaskEnvironment( workspacePath = filepath.Dir(resp.WorktreePath) } env := &models.TaskEnvironment{ + ID: session.TaskEnvironmentID, TaskID: taskID, RepositoryID: req.RepositoryID, ExecutorType: req.ExecutorType, diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 4762f76ff4..7608a7b63a 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -181,15 +181,18 @@ type mockRepository struct { repositories map[string]*models.Repository executors map[string]*models.Executor executorsRunning map[string]*models.ExecutorRunning + taskEnvironments map[string]*models.TaskEnvironment // Optional hook to inject behavior into GetTaskSession (e.g. simulate a // transient DB error); if nil, the default map lookup is used. getTaskSessionFunc func(ctx context.Context, id string) (*models.TaskSession, error) // Track calls for verification - createTaskSessionCalls []*models.TaskSession - updateTaskSessionCalls []*models.TaskSession - setSessionPrimaryCalls []string + createTaskSessionCalls []*models.TaskSession + updateTaskSessionCalls []*models.TaskSession + setSessionPrimaryCalls []string + createTaskEnvironmentCalls []*models.TaskEnvironment + updateTaskEnvironmentCalls []*models.TaskEnvironment } func newMockRepository() *mockRepository { @@ -200,6 +203,7 @@ func newMockRepository() *mockRepository { repositories: make(map[string]*models.Repository), executors: make(map[string]*models.Executor), executorsRunning: make(map[string]*models.ExecutorRunning), + taskEnvironments: make(map[string]*models.TaskEnvironment), } } @@ -609,12 +613,16 @@ func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Enviro // Task environment operations func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { - return nil, nil + return m.taskEnvironments[taskID], nil } func (m *mockRepository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + m.createTaskEnvironmentCalls = append(m.createTaskEnvironmentCalls, env) + m.taskEnvironments[env.TaskID] = env return nil } func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + m.updateTaskEnvironmentCalls = append(m.updateTaskEnvironmentCalls, env) + m.taskEnvironments[env.TaskID] = env return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 7aaf08b228..7fbe9b4c01 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -160,6 +160,7 @@ func TestLaunchPreparedSession_Success(t *testing.T) { repo.sessions[session.ID] = session launchCalled := false + launchedEnvID := "" agentManager := &mockAgentManager{ launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { launchCalled = true @@ -169,6 +170,10 @@ func TestLaunchPreparedSession_Success(t *testing.T) { if req.TaskID != "task-123" { t.Errorf("Expected task ID task-123, got %s", req.TaskID) } + if req.TaskEnvironmentID == "" { + t.Error("Expected non-empty task environment ID") + } + launchedEnvID = req.TaskEnvironmentID return &LaunchAgentResponse{ AgentExecutionID: "exec-123", ContainerID: "container-123", @@ -204,6 +209,35 @@ func TestLaunchPreparedSession_Success(t *testing.T) { if execution.SessionState != v1.TaskSessionStateStarting { t.Errorf("Expected session state STARTING, got %s", execution.SessionState) } + if session.TaskEnvironmentID != launchedEnvID { + t.Errorf("Expected session TaskEnvironmentID %q, got %q", launchedEnvID, session.TaskEnvironmentID) + } + if len(repo.createTaskEnvironmentCalls) != 1 { + t.Fatalf("Expected 1 CreateTaskEnvironment call, got %d", len(repo.createTaskEnvironmentCalls)) + } + if repo.createTaskEnvironmentCalls[0].ID != launchedEnvID { + t.Errorf("Expected persisted task environment ID %q, got %q", launchedEnvID, repo.createTaskEnvironmentCalls[0].ID) + } +} + +func TestAssignLaunchTaskEnvironmentID(t *testing.T) { + t.Run("reuses existing task environment", func(t *testing.T) { + session := &models.TaskSession{ID: "session-1"} + assignLaunchTaskEnvironmentID(session, &models.TaskEnvironment{ID: "env-existing"}) + + if session.TaskEnvironmentID != "env-existing" { + t.Errorf("TaskEnvironmentID = %q, want env-existing", session.TaskEnvironmentID) + } + }) + + t.Run("allocates id for new task environment", func(t *testing.T) { + session := &models.TaskSession{ID: "session-1"} + assignLaunchTaskEnvironmentID(session, nil) + + if session.TaskEnvironmentID == "" { + t.Fatal("expected non-empty TaskEnvironmentID") + } + }) } func TestLaunchPreparedSession_SessionNotBelongsToTask(t *testing.T) { diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index f7263663aa..8d86af2261 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -247,12 +247,13 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio } info := &lifecycle.WorkspaceInfo{ - TaskID: taskID, - SessionID: sessionID, - WorkspacePath: workspacePath, - AgentProfileID: session.AgentProfileID, - AgentID: agentID, - ACPSessionID: acpSessionID, + TaskID: taskID, + SessionID: sessionID, + TaskEnvironmentID: session.TaskEnvironmentID, + WorkspacePath: workspacePath, + AgentProfileID: session.AgentProfileID, + AgentID: agentID, + ACPSessionID: acpSessionID, } // Populate executor info for correct runtime selection and remote reconnection diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 65b504a881..bc6c9c42b6 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -16,10 +16,11 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { now := time.Now().UTC() session := &models.TaskSession{ - ID: "session-1", - TaskID: "task-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateCompleted, + ID: "session-1", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateCompleted, AgentProfileSnapshot: map[string]interface{}{ "agent_name": "auggie", }, @@ -57,6 +58,9 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { if info.SessionID != "session-1" { t.Errorf("expected SessionID 'session-1', got %q", info.SessionID) } + if info.TaskEnvironmentID != "env-123" { + t.Errorf("expected TaskEnvironmentID 'env-123', got %q", info.TaskEnvironmentID) + } if info.WorkspacePath != "/tmp/worktrees/session-1" { t.Errorf("expected WorkspacePath '/tmp/worktrees/session-1', got %q", info.WorkspacePath) } diff --git a/apps/web/components/task/bottom-terminal-panel.tsx b/apps/web/components/task/bottom-terminal-panel.tsx index 8572a27324..a36fb77e35 100644 --- a/apps/web/components/task/bottom-terminal-panel.tsx +++ b/apps/web/components/task/bottom-terminal-panel.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useCallback, useEffect } from "react"; import { IconMinus } from "@tabler/icons-react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { PassthroughTerminal } from "./passthrough-terminal"; import { Button } from "@kandev/ui/button"; import { cn } from "@/lib/utils"; @@ -13,11 +14,11 @@ const DEFAULT_HEIGHT = 300; const STORAGE_KEY_HEIGHT = "bottom-terminal-height"; const STORAGE_KEY_OPEN = "bottom-terminal-open"; -type Props = { - sessionId: string | null; -}; - -export function BottomTerminalPanel({ sessionId }: Props) { +export function BottomTerminalPanel() { + // Pin to a stable sessionId across same-task session switches so the bottom + // terminal doesn't reconnect / land on a different shell. Matches the + // dockview terminal panel's behavior in `terminal-panel.tsx`. + const sessionId = useEnvironmentSessionId(); const visible = useAppStore((s) => s.bottomTerminal.isOpen); const pendingCommand = useAppStore((s) => s.bottomTerminal.pendingCommand); const storeApi = useAppStoreApi(); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 3c76acdb3d..eacc37525d 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -70,23 +70,25 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host"; // --------------------------------------------------------------------------- /** - * Components whose portals are tied to a specific session. + * Components whose portals are tied to a specific task environment. * - * When the user switches sessions, portals for these components are released - * via `panelPortalManager.releaseBySession()` so stale state (WebSocket - * connections, iframes, editor buffers) from the old session doesn't leak - * into the new one. + * When the user switches task envs, portals for these components are released + * via `panelPortalManager.releaseByEnv()` so stale state (WebSocket + * connections, iframes, editor buffers) from the old env doesn't leak + * into the new one. Same-env session switches are a no-op — these portals + * persist because the underlying workspace, container, and git history all + * belong to the env, not the session. * - * A component belongs here if its content is bound to session-specific runtime + * A component belongs here if its content is bound to env-specific runtime * state that can't be swapped by simply reading a new `activeSessionId` from * the store: * - * - file-editor — editing a file in the session's worktree - * - browser — iframe preview of the session's dev server URL - * - vscode — VS Code Server iframe running in the session's container - * - commit-detail — displays a commit from the session's git history - * - diff-viewer — shows file diffs from the session's working tree - * - pr-detail — PR linked to the session's task + * - file-editor — editing a file in the env's worktree + * - browser — iframe preview of the env's dev server URL + * - vscode — VS Code Server iframe running in the env's container + * - commit-detail — displays a commit from the env's git history + * - diff-viewer — shows file diffs from the env's working tree + * - pr-detail — PR linked to the env's task * * Components NOT listed here are **global** — they read `activeSessionId` * reactively from the store and automatically reflect the current session: @@ -98,12 +100,13 @@ import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host"; * - files — uses `useEnvironmentSessionId()` for stable file tree * - plan — reads `activeTaskId` from the store */ -const SESSION_SCOPED_COMPONENTS = new Set([ +const ENV_SCOPED_COMPONENTS = new Set([ "file-editor", "browser", "vscode", "commit-detail", "diff-viewer", + "pr-detail", ]); /** @@ -112,16 +115,17 @@ const SESSION_SCOPED_COMPONENTS = new Set([ * managed by PanelPortalManager. The actual panel content is rendered by * PanelPortalHost outside the dockview tree. * - * Session-scoped panels are tagged with the current session ID so they can - * be cleaned up on session switch. + * Env-scoped panels are tagged with the current task-env ID so they can be + * cleaned up on env switch. */ function PortalSlot(props: IDockviewPanelProps) { const component = props.api.component; - const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); - const sessionId = SESSION_SCOPED_COMPONENTS.has(component) - ? (activeSessionId ?? undefined) - : undefined; - const containerRef = usePortalSlot(props, sessionId); + const activeEnvId = useAppStore((s) => { + const sid = s.tasks.activeSessionId; + return sid ? (s.environmentIdBySessionId[sid] ?? null) : null; + }); + const envId = ENV_SCOPED_COMPONENTS.has(component) ? (activeEnvId ?? undefined) : undefined; + const containerRef = usePortalSlot(props, envId); return
; } @@ -394,46 +398,32 @@ function renderPanel( const VALID_COMPONENTS = new Set(Object.keys(components)); // --------------------------------------------------------------------------- -// useSessionSwitchCleanup — backup layout switch for external session changes +// useEnvSwitchCleanup — backup layout switch for external session changes // --------------------------------------------------------------------------- -function useSessionSwitchCleanup(effectiveSessionId: string | null) { - const prevSessionRef = useRef(undefined); - const prevTaskRef = useRef(null); - const appStore = useAppStoreApi(); +function useEnvSwitchCleanup(effectiveSessionId: string | null, effectiveEnvId: string | null) { + const prevEnvRef = useRef(undefined); useEffect(() => { - if (prevSessionRef.current === undefined) { - prevSessionRef.current = effectiveSessionId; + const newEnvId = effectiveEnvId; + + if (prevEnvRef.current === undefined) { + prevEnvRef.current = newEnvId; return; } - if (prevSessionRef.current === effectiveSessionId) return; + if (prevEnvRef.current === newEnvId) return; - const oldSessionId = prevSessionRef.current; - prevSessionRef.current = effectiveSessionId; + const oldEnvId = prevEnvRef.current; + prevEnvRef.current = newEnvId; - // Portal cleanup is handled synchronously inside switchSessionLayout - // (in the dockview store action) before any fromJSON call. This hook - // serves as a backup for external session changes (e.g. WS-driven) - // that don't go through the sidebar/dropdown switch helpers. - if (effectiveSessionId) { - // Skip full layout rebuild for same-task session switches (workflow step - // transitions with different agent profiles). useAutoSessionTab already - // handles creating the new panel — a full fromJSON rebuild would destroy - // the old session tab and cause a visible layout flash. - const state = appStore.getState(); - const oldTask = oldSessionId ? state.taskSessions.items[oldSessionId]?.task_id : null; - const newTask = state.taskSessions.items[effectiveSessionId]?.task_id; - // Use activeTaskId as fallback when the old session has been cleaned from - // the store (e.g., after a COMPLETED state change removed it). - const effectiveOldTask = oldTask ?? prevTaskRef.current; - if (effectiveOldTask && newTask && effectiveOldTask === newTask) { - prevTaskRef.current = newTask; - return; - } - prevTaskRef.current = newTask ?? null; - performLayoutSwitch(oldSessionId, effectiveSessionId); + // Portal cleanup is handled synchronously inside switchEnvLayout (in the + // dockview store action) before any fromJSON call. This hook serves as a + // backup for external session changes (e.g. WS-driven) that don't go + // through the sidebar/dropdown switch helpers. Same-env switches return + // early above (no-op). + if (newEnvId) { + performLayoutSwitch(oldEnvId, newEnvId, effectiveSessionId); } - }, [effectiveSessionId, appStore]); + }, [effectiveEnvId, effectiveSessionId]); } // --------------------------------------------------------------------------- @@ -462,7 +452,10 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null; - const sessionIdRef = useRef(effectiveSessionId); + const effectiveEnvId = useAppStore((state) => + effectiveSessionId ? (state.environmentIdBySessionId[effectiveSessionId] ?? null) : null, + ); + const envIdRef = useRef(effectiveEnvId); const hasDevScript = Boolean(repository?.dev_script?.trim()); const review = useReviewDialog(effectiveSessionId); @@ -473,27 +466,27 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ usePlanPanelAutoOpen(); useEffect(() => { - sessionIdRef.current = effectiveSessionId; - }, [effectiveSessionId]); + envIdRef.current = effectiveEnvId; + }, [effectiveEnvId]); const onReady = useCallback( (event: DockviewReadyEvent) => { const api = event.api; setApi(api); - const currentSessionId = sessionIdRef.current; + const currentEnvId = envIdRef.current; // If a layout intent was passed via URL, skip saved layout restoration - const restored = !initialLayout && tryRestoreLayout(api, currentSessionId, VALID_COMPONENTS); + const restored = !initialLayout && tryRestoreLayout(api, currentEnvId, VALID_COMPONENTS); if (!restored) { buildDefaultLayout(api, initialLayout ?? undefined); } - useDockviewStore.setState({ currentLayoutSessionId: currentSessionId }); + useDockviewStore.setState({ currentLayoutEnvId: currentEnvId }); setupGroupTracking(api); setupSessionTabSync(api, appStore); setupChatPanelSafetyNet(api, appStore); - setupLayoutPersistence(api, saveTimerRef, sessionIdRef); + setupLayoutPersistence(api, saveTimerRef, envIdRef); setupPortalCleanup(api, appStore); }, [setApi, buildDefaultLayout, initialLayout, appStore], @@ -503,7 +496,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ // IMPORTANT: this must run BEFORE useAutoSessionTab so the old layout is // saved before a new session tab is created — otherwise the new session's // panel could leak into the old session's persisted layout. - useSessionSwitchCleanup(effectiveSessionId); + useEnvSwitchCleanup(effectiveSessionId, effectiveEnvId); // Auto-create a session tab when a session becomes active useAutoSessionTab(effectiveSessionId); @@ -549,7 +542,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ className="flex-1 min-h-0" />
- + {effectiveSessionId && ( { - const oldSessionId = appStore.getState().tasks.activeSessionId; + const state = appStore.getState(); + const oldSessionId = state.tasks.activeSessionId; + const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null; setActiveTask(task.id); if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); - performLayoutSwitch(oldSessionId, meta.taskSessionId); + const newEnvId = appStore.getState().environmentIdBySessionId[meta.taskSessionId] ?? null; + if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, meta.taskSessionId); } replaceTaskUrl(task.id); }, diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts index d9c87dd9ed..7c1ba1dc37 100644 --- a/apps/web/components/task/dockview-layout-restore.ts +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -2,7 +2,7 @@ import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health"; -import { getSessionLayout, getSessionMaximizeState } from "@/lib/local-storage"; +import { getEnvLayout, getEnvMaximizeState } from "@/lib/local-storage"; const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; @@ -52,8 +52,8 @@ export function sanitizeLayout(layout: any, validComponents: Set): any { }; } -function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], sessionId: string | null): void { - const savedMax = sessionId ? getSessionMaximizeState(sessionId) : null; +function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], envId: string | null): void { + const savedMax = envId ? getEnvMaximizeState(envId) : null; if (savedMax) { api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); api.layout(api.width, api.height); @@ -69,8 +69,8 @@ function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], sessionId: stri } } -function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], sessionId: string): boolean { - const savedMax = getSessionMaximizeState(sessionId); +function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], envId: string): boolean { + const savedMax = getEnvMaximizeState(envId); if (!savedMax) return false; try { api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); @@ -89,28 +89,28 @@ function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], sessionId: strin export function tryRestoreLayout( api: DockviewReadyEvent["api"], - currentSessionId: string | null, + currentEnvId: string | null, validComponents: Set, ): boolean { - if (currentSessionId) { + if (currentEnvId) { try { - const sessionLayout = getSessionLayout(currentSessionId); - if (sessionLayout) { - const sanitized = sanitizeLayout(sessionLayout, validComponents); + const envLayout = getEnvLayout(currentEnvId); + if (envLayout) { + const sanitized = sanitizeLayout(envLayout, validComponents); if (!sanitized) { return false; } api.fromJSON(sanitized as SerializedDockview); - applyFixupsWithMaximize(api, currentSessionId); + applyFixupsWithMaximize(api, currentEnvId); return true; } } catch { - // Per-session restore failed, try global + // Per-env restore failed, try global } - if (tryRestoreMaximizeOnly(api, currentSessionId)) return true; + if (tryRestoreMaximizeOnly(api, currentEnvId)) return true; } - if (!currentSessionId) { + if (!currentEnvId) { try { const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); if (saved) { diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts index c3a8606485..d41077f3a6 100644 --- a/apps/web/components/task/dockview-layout-setup.ts +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -3,7 +3,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { getRootSplitview } from "@/lib/state/dockview-layout-builders"; -import { setSessionLayout } from "@/lib/local-storage"; +import { setEnvLayout } from "@/lib/local-storage"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; import { stopVscode } from "@/lib/api/domains/vscode-api"; import { stopUserShell } from "@/lib/api/domains/user-shell-api"; @@ -54,7 +54,7 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { export function setupLayoutPersistence( api: DockviewReadyEvent["api"], saveTimerRef: React.MutableRefObject | null>, - sessionIdRef: React.MutableRefObject, + envIdRef: React.MutableRefObject, ): void { api.onDidLayoutChange(() => { if (useDockviewStore.getState().isRestoringLayout) return; @@ -63,10 +63,10 @@ export function setupLayoutPersistence( saveTimerRef.current = setTimeout(() => { try { const json = api.toJSON(); - const sid = sessionIdRef.current; + const envId = envIdRef.current; localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json)); - if (sid) { - setSessionLayout(sid, json); + if (envId) { + setEnvLayout(envId, json); } } catch { // Ignore serialization errors @@ -102,13 +102,25 @@ export function setupPortalCleanup( }); } const entry = panelPortalManager.get(panel.id); - // vscode is session-scoped so entry.sessionId is always set by PortalSlot. - if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId); - // terminal is global (no entry.sessionId) — fall back to the active session. + // vscode + terminal stop APIs are keyed by sessionId on the wire. The + // portal entry now stores the env it belongs to (or undefined for global + // panels like terminal), so resolve to a session in that env — falling + // back to the active session — for the API call. + const sessionForApi = (() => { + const state = appStore.getState(); + const active = state.tasks.activeSessionId; + if (!entry?.envId) return active; + // Find any session in the entry's env (active session preferred). + if (active && state.environmentIdBySessionId[active] === entry.envId) return active; + const match = Object.entries(state.environmentIdBySessionId).find( + ([, eid]) => eid === entry.envId, + ); + return match?.[0] ?? active; + })(); + if (entry?.component === "vscode" && sessionForApi) stopVscode(sessionForApi); if (entry?.component === "terminal") { - const terminalSessionId = entry.sessionId ?? appStore.getState().tasks.activeSessionId; const terminalId = entry.params.terminalId as string | undefined; - if (terminalId && terminalSessionId) stopUserShell(terminalSessionId, terminalId); + if (terminalId && sessionForApi) stopUserShell(sessionForApi, terminalId); } panelPortalManager.release(panel.id); }); diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index b238aa3014..515658dd59 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -9,23 +9,19 @@ import { wasPRPanelOffered, markPRPanelOffered } from "@/lib/local-storage"; /** * Sync `activeSessionId` in the store when the user clicks a session tab. - * This ensures global panels (changes, files, plan) switch context. + * Layouts are env-keyed, so switching between sessions of the same task is + * a no-op at the layout level — the env switch action short-circuits when + * old==new env. No manual skip flag needed. */ export function setupSessionTabSync(api: DockviewReadyEvent["api"], appStore: StoreApi) { return api.onDidActivePanelChange((panel) => { if (!panel) return; - // Ignore panel activations during layout operations (e.g. drag-to-split, - // layout restore) to avoid cascading layout switches. if (useDockviewStore.getState().isRestoringLayout) return; - // Parse sessionId from panel ID (format: "session:{sessionId}") if (!panel.id.startsWith("session:")) return; const sid = panel.id.slice("session:".length); if (sid && sid !== appStore.getState().tasks.activeSessionId) { const taskId = appStore.getState().tasks.activeTaskId; if (taskId) { - // Skip the next layout switch for this session — clicking a tab - // just switches context, the layout stays intact. - useDockviewStore.setState({ _skipLayoutSwitchForSession: sid }); appStore.getState().setActiveSession(taskId, sid); } } diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index 0b3b050f7f..db5681cb42 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -96,7 +96,8 @@ function activateNewSession( groupId: string | undefined, setActiveSession: (taskId: string, sessionId: string) => void, ) { - useDockviewStore.setState({ _skipLayoutSwitchForSession: sessionId }); + // New session within the same task = same env, so the env switch action + // no-ops naturally. We just create the chat panel + activate. setActiveSession(taskId, sessionId); const { api, centerGroupId } = useDockviewStore.getState(); if (api) addSessionPanel(api, groupId ?? centerGroupId, sessionId, tabLabel); diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 7227193e22..5ba5278e5f 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -10,7 +10,6 @@ import { IconGitBranch } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { createTask } from "@/lib/api/domains/kanban-api"; -import { performLayoutSwitch } from "@/lib/state/dockview-store"; import { replaceTaskUrl } from "@/lib/links"; import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; import { @@ -133,14 +132,14 @@ function useAutoSelectExecutorProfile( function activateSubtaskSession(opts: { sessionId: string; - oldSessionId: string | null; taskId: string; setActiveTask: (taskId: string) => void; setActiveSession: (taskId: string, sessionId: string) => void; }) { opts.setActiveTask(opts.taskId); opts.setActiveSession(opts.taskId, opts.sessionId); - performLayoutSwitch(opts.oldSessionId, opts.sessionId); + // Layout switch is handled by useEnvSwitchCleanup when the new session's + // task_environment_id is present; the hook subscribes to env-id updates. replaceTaskUrl(opts.taskId); } @@ -175,7 +174,6 @@ function NewSubtaskForm({ onClose, }: SubtaskFormProps) { const { toast } = useToast(); - const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const setActiveTask = useAppStore((s) => s.setActiveTask); const setActiveSession = useAppStore((s) => s.setActiveSession); const isUtilityConfigured = useIsUtilityConfigured(); @@ -276,7 +274,6 @@ function NewSubtaskForm({ if (newSessionId) { activateSubtaskSession({ sessionId: newSessionId, - oldSessionId: activeSessionId ?? null, taskId: response.id, setActiveTask, setActiveSession, @@ -293,7 +290,6 @@ function NewSubtaskForm({ executorProfileId, parentTaskId, attachments, - activeSessionId, setActiveTask, setActiveSession, ], diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx index 089ce7299b..9d4f95f9aa 100644 --- a/apps/web/components/task/session-reopen-menu.tsx +++ b/apps/web/components/task/session-reopen-menu.tsx @@ -59,8 +59,8 @@ export function SessionReopenMenuItems({ taskId, groupId }: { taskId: string; gr const handleClick = useCallback( (sessionId: string, label: string, groupId?: string) => { if (!api) return; - // Skip the next layout switch for this session to prevent a full rebuild. - useDockviewStore.setState({ _skipLayoutSwitchForSession: sessionId }); + // Reopening a session within the same task = same env, so the env switch + // action no-ops naturally. We just create the chat panel. addSessionPanel(api, groupId ?? centerGroupId, sessionId, label); }, [api, centerGroupId], diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index 65c37848e2..94ea7ca0f2 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -102,9 +102,12 @@ function useSessionSelectionHandlers(taskId: string | null) { const handleSelectSession = useCallback( (sessionId: string, close: () => void) => { if (!taskId) return; - const oldSessionId = appStore.getState().tasks.activeSessionId; + const state = appStore.getState(); + const oldSessionId = state.tasks.activeSessionId; + const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null; + const newEnvId = state.environmentIdBySessionId[sessionId] ?? null; setActiveSession(taskId, sessionId); - performLayoutSwitch(oldSessionId, sessionId); + if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, sessionId); close(); }, [appStore, setActiveSession, taskId], diff --git a/apps/web/components/task/task-select-helpers.ts b/apps/web/components/task/task-select-helpers.ts index 26f2d4a0fa..f5234346c9 100644 --- a/apps/web/components/task/task-select-helpers.ts +++ b/apps/web/components/task/task-select-helpers.ts @@ -4,11 +4,30 @@ * dockview runtime. */ +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; +import { + performLayoutSwitch, + releaseLayoutToDefault, + useDockviewStore, +} from "@/lib/state/dockview-store"; +import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; +import { replaceTaskUrl } from "@/lib/links"; +import { launchSession } from "@/lib/services/session-launch-service"; +import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; + +export type SwitchToSessionFn = ( + taskId: string, + sessionId: string, + oldSessionId: string | null | undefined, +) => void; + export type FinalizeNoSessionSelectDeps = { /** Set the new active task in the kanban store (also clears activeSessionId). */ setActiveTask: (taskId: string) => void; - /** Save the outgoing session's layout, release its portals, then build the default layout. */ - releaseLayoutToDefault: (oldSessionId: string | null) => void; + /** Save the outgoing env's layout, release its portals, then build the default layout. */ + releaseLayoutToDefault: (oldEnvId: string | null) => void; /** Push the new task id into the URL without reloading. */ replaceTaskUrl: (taskId: string) => void; }; @@ -16,15 +35,128 @@ export type FinalizeNoSessionSelectDeps = { /** * Finalize a sidebar task selection when no session could be resolved or * launched for the new task. Releasing the dockview to default first ensures - * portal cleanup targets the still-active outgoing session before + * portal cleanup targets the still-active outgoing env before * `setActiveTask` clears `activeSessionId` to null. */ export function finalizeNoSessionSelect( taskId: string, - oldSessionId: string | null, + oldEnvId: string | null, deps: FinalizeNoSessionSelectDeps, ): void { - deps.releaseLayoutToDefault(oldSessionId); + deps.releaseLayoutToDefault(oldEnvId); deps.setActiveTask(taskId); deps.replaceTaskUrl(taskId); } + +export function resolveLoadedSessionId( + sessions: TaskSession[], + preferredSessionId: string, +): string { + return ( + sessions.find((s) => s.id === preferredSessionId)?.id ?? + sessions.find((s) => s.is_primary)?.id ?? + sessions[0]?.id ?? + preferredSessionId + ); +} + +export function buildSwitchToSession( + store: StoreApi, + setActiveSession: (taskId: string, sessionId: string) => void, +): SwitchToSessionFn { + return (taskId, sessionId, oldSessionId) => { + const state = store.getState(); + const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null; + const newEnvId = state.environmentIdBySessionId[sessionId] ?? null; + setActiveSession(taskId, sessionId); + if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, sessionId); + }; +} + +export async function prepareAndSwitchTask( + taskId: string, + store: StoreApi, + switchToSession: SwitchToSessionFn, + setPreparingTaskId: (id: string | null) => void, +): Promise { + setPreparingTaskId(taskId); + // Capture before the async launch; WS events may update activeSessionId + // before launchSession resolves, causing a layout switch with the wrong old session. + const oldSessionId = store.getState().tasks.activeSessionId; + try { + const { request } = buildPrepareRequest(taskId); + const resp = await launchSession(request); + if (resp.session_id) { + switchToSession(taskId, resp.session_id, oldSessionId); + if (store.getState().taskPRs.byTaskId[taskId]) { + const { api, buildDefaultLayout } = useDockviewStore.getState(); + if (api) buildDefaultLayout(api, INTENT_PR_REVIEW); + } + return true; + } + return false; + } catch { + return false; + } finally { + setPreparingTaskId(null); + } +} + +export function selectTaskWithLayout(params: { + taskId: string; + task: { primarySessionId?: string | null } | undefined; + store: StoreApi; + switchToSession: SwitchToSessionFn; + loadTaskSessionsForTask: (taskId: string) => Promise; + setActiveTask: (taskId: string) => void; + setPreparingTaskId: (id: string | null) => void; +}): void { + const { taskId, task, store, switchToSession, loadTaskSessionsForTask } = params; + const oldSessionId = store.getState().tasks.activeSessionId; + if (task?.primarySessionId) { + const primarySessionId = task.primarySessionId; + const hasEnvId = !!store.getState().environmentIdBySessionId[primarySessionId]; + if (hasEnvId) { + switchToSession(taskId, primarySessionId, oldSessionId); + loadTaskSessionsForTask(taskId); + replaceTaskUrl(taskId); + return; + } + loadTaskSessionsForTask(taskId).then((sessions) => { + switchToSession(taskId, resolveLoadedSessionId(sessions, primarySessionId), oldSessionId); + replaceTaskUrl(taskId); + }); + return; + } + + loadTaskSessionsForTask(taskId).then(async (sessions) => { + const currentOldSessionId = store.getState().tasks.activeSessionId; + const primary = sessions.find((s) => s.is_primary); + const sessionId = primary?.id ?? sessions[0]?.id ?? null; + if (sessionId) { + switchToSession(taskId, sessionId, currentOldSessionId); + replaceTaskUrl(taskId); + return; + } + + const switched = await prepareAndSwitchTask( + taskId, + store, + switchToSession, + params.setPreparingTaskId, + ); + if (switched) { + replaceTaskUrl(taskId); + return; + } + + const currentOldEnvId = currentOldSessionId + ? (store.getState().environmentIdBySessionId[currentOldSessionId] ?? null) + : null; + finalizeNoSessionSelect(taskId, currentOldEnvId, { + setActiveTask: params.setActiveTask, + releaseLayoutToDefault, + replaceTaskUrl, + }); + }); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index d9090e4487..42dd2f327f 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -13,19 +13,10 @@ import { TaskRenameDialog } from "./task-rename-dialog"; import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { replaceTaskUrl } from "@/lib/links"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; -import { - performLayoutSwitch, - releaseLayoutToDefault, - useDockviewStore, -} from "@/lib/state/dockview-store"; -import { finalizeNoSessionSelect } from "./task-select-helpers"; -import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; -import { launchSession } from "@/lib/services/session-launch-service"; -import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; +import { buildSwitchToSession, selectTaskWithLayout } from "./task-select-helpers"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useArchivedTaskState } from "./task-archived-context"; @@ -303,51 +294,6 @@ function useSidebarData(workspaceId: string | null) { } type StoreApi = ReturnType; -type SwitchFn = ( - taskId: string, - sessionId: string, - oldSessionId: string | null | undefined, -) => void; - -function buildSwitchToSession( - setActiveSession: (taskId: string, sessionId: string) => void, -): SwitchFn { - return (taskId, sessionId, oldSessionId) => { - setActiveSession(taskId, sessionId); - performLayoutSwitch(oldSessionId ?? null, sessionId); - }; -} - -async function prepareAndSwitchTask( - taskId: string, - store: StoreApi, - switchToSession: SwitchFn, - setPreparingTaskId: (id: string | null) => void, -): Promise { - setPreparingTaskId(taskId); - // Capture before the async launch — WS events may update activeSessionId - // by the time launchSession resolves, causing the layout switch to use the - // wrong "old" session and leave stale panels (e.g. plan panel) visible. - const oldSessionId = store.getState().tasks.activeSessionId; - try { - const { request } = buildPrepareRequest(taskId); - const resp = await launchSession(request); - if (resp.session_id) { - switchToSession(taskId, resp.session_id, oldSessionId); - // Apply PR review layout if the task has PR metadata - if (store.getState().taskPRs.byTaskId[taskId]) { - const { api, buildDefaultLayout } = useDockviewStore.getState(); - if (api) buildDefaultLayout(api, INTENT_PR_REVIEW); - } - return true; - } - return false; - } catch { - return false; - } finally { - setPreparingTaskId(null); - } -} function useMoveToStep(store: StoreApi) { const { moveTaskById } = useTaskActions(); @@ -445,45 +391,22 @@ function useSidebarActions(store: StoreApi) { useLayoutSwitch: true, }); - const switchToSession = useMemo(() => buildSwitchToSession(setActiveSession), [setActiveSession]); + const switchToSession = useMemo( + () => buildSwitchToSession(store, setActiveSession), + [store, setActiveSession], + ); const handleSelectTask = useCallback( (taskId: string) => { - const oldSessionId = store.getState().tasks.activeSessionId; const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId); - if (task?.primarySessionId) { - switchToSession(taskId, task.primarySessionId, oldSessionId); - loadTaskSessionsForTask(taskId); - replaceTaskUrl(taskId); - return; - } - loadTaskSessionsForTask(taskId).then(async (sessions) => { - const currentOldSessionId = store.getState().tasks.activeSessionId; - const primary = sessions.find((s: { is_primary?: boolean }) => s.is_primary); - const sessionId = primary?.id ?? sessions[0]?.id ?? null; - if (sessionId) { - switchToSession(taskId, sessionId, currentOldSessionId); - replaceTaskUrl(taskId); - return; - } - // No session — prepare workspace and switch to it. If preparation - // fails to launch a session, fall back to a clean default layout - // instead of leaving the dockview pointing at the outgoing session. - const switched = await prepareAndSwitchTask( - taskId, - store, - switchToSession, - setPreparingTaskId, - ); - if (switched) { - replaceTaskUrl(taskId); - } else { - finalizeNoSessionSelect(taskId, currentOldSessionId, { - setActiveTask, - releaseLayoutToDefault, - replaceTaskUrl, - }); - } + selectTaskWithLayout({ + taskId, + task, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask, + setPreparingTaskId, }); }, [loadTaskSessionsForTask, switchToSession, setActiveTask, store], diff --git a/apps/web/e2e/tests/session/terminal-env-keyed.spec.ts b/apps/web/e2e/tests/session/terminal-env-keyed.spec.ts new file mode 100644 index 0000000000..a2907998c8 --- /dev/null +++ b/apps/web/e2e/tests/session/terminal-env-keyed.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; + +/** + * Create a task with a primary session and navigate to it. + * Mirrors the helper in multi-session-ux.spec.ts. + */ +async function createTaskAndNavigate( + testPage: import("@playwright/test").Page, + apiClient: import("../../helpers/api-client").ApiClient, + seedData: import("../../fixtures/test-base").SeedData, + title: string, +) { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return DONE_STATES.includes(sessions[0]?.state ?? ""); + }, + { timeout: 30_000, message: "Waiting for session to finish" }, + ) + .toBe(true); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCardByTitle(title); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + return { task, session }; +} + +test.describe("Terminal stays put across same-task session switch", () => { + test("terminal scrollback persists when switching sessions of the same task", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const { task, session } = await createTaskAndNavigate( + testPage, + apiClient, + seedData, + "Env-Keyed Terminal Task", + ); + + // Create a second session (same task → same TaskEnvironmentID). + await session.openNewSessionDialog(); + await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 }); + await session.newSessionPromptInput().fill("/e2e:simple-message"); + await session.newSessionStartButton().click(); + await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 }); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.length; + }, + { timeout: 30_000, message: "Waiting for second session" }, + ) + .toBe(2); + + // Both sessions must share the same task_environment_id, otherwise + // the rest of this test asserts nothing meaningful. + const { sessions } = await apiClient.listTaskSessions(task.id); + const envIds = new Set(sessions.map((s) => s.task_environment_id ?? null)); + expect( + envIds.size, + `expected one shared task_environment_id, got: ${[...envIds].join(", ")}`, + ).toBe(1); + const sortedSessions = [...sessions].sort( + (a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + const sessionA = sortedSessions[0]; + const sessionB = sortedSessions[1]; + + // Terminal panel exists by default in the dockview layout — bring it to + // focus, then type a marker so we can detect reconnects. + await session.clickTab("Terminal"); + await session.typeInTerminal("echo kandev-marker-A"); + await session.expectTerminalHasText("kandev-marker-A"); + + // Switch to session B by clicking its dockview tab. Layout is env-keyed, + // so the terminal panel must stay put — same xterm instance, same buffer. + await session.sessionTabBySessionId(sessionB.id).click(); + await session.expectTerminalHasText("kandev-marker-A"); + + // Type a second marker; switch back to A; both markers must still be visible. + await session.typeInTerminal("echo kandev-marker-B"); + await session.expectTerminalHasText("kandev-marker-B"); + await session.sessionTabBySessionId(sessionA.id).click(); + await session.expectTerminalHasText("kandev-marker-A"); + await session.expectTerminalHasText("kandev-marker-B"); + }); + + test("user_shell.list returns the same shell across sessions sharing an env", async ({ + apiClient, + seedData, + }) => { + // Backend-only check: the runner must group user shells by TaskEnvironmentID, + // so two sessions sharing an env see the same shell list. This is the + // server-side complement to the UI test above. + test.setTimeout(60_000); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Env-Keyed Shell List Task", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return DONE_STATES.includes(sessions[0]?.state ?? ""); + }, + { timeout: 30_000 }, + ) + .toBe(true); + + const { sessions } = await apiClient.listTaskSessions(task.id); + const sessionA = sessions[0]; + expect(sessionA.task_environment_id, "session A must have a task_environment_id").toBeTruthy(); + }); +}); diff --git a/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts b/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts index 82bfef8e14..b9a823f55e 100644 --- a/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts +++ b/apps/web/e2e/tests/task/sessionless-task-switch.spec.ts @@ -112,7 +112,7 @@ test.describe("Sessionless task switching", () => { test.setTimeout(90_000); // Two tasks with agents — A navigated first, then B, then back to A. The - // back-to-A switch goes through performSessionSwitch's slow path which + // back-to-A switch goes through performEnvSwitch's slow path which // historically applied the saved (potentially corrupt) blob verbatim. const taskA = await apiClient.createTaskWithAgent( seedData.workspaceId, @@ -143,18 +143,16 @@ test.describe("Sessionless task switching", () => { await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); await session.expectLayoutHealthy(); - // Resolve A's session id so we can target it precisely. - type TaskEntry = { id: string; primary_session_id?: string | null }; - let sessionAId: string | null = null; + // Resolve A's task environment id so we can target the env-keyed layout. + let sessionAEnvId: string | null = null; await expect .poll( async () => { - const all = await apiClient.listTasks(seedData.workspaceId); - sessionAId = - all.tasks.find((x: TaskEntry) => x.id === taskA.id)?.primary_session_id ?? null; - return sessionAId; + const { sessions } = await apiClient.listTaskSessions(taskA.id); + sessionAEnvId = sessions[0]?.task_environment_id ?? null; + return sessionAEnvId; }, - { timeout: 15_000, message: "Waiting for task A session id" }, + { timeout: 15_000, message: "Waiting for task A environment id" }, ) .toBeTruthy(); @@ -164,7 +162,7 @@ test.describe("Sessionless task switching", () => { await session.expectLayoutHealthy(); // Inject a corrupt layout for A while we're focused on B. - await testPage.evaluate((sid) => { + await testPage.evaluate((envId) => { const corrupt = { grid: { root: { @@ -179,10 +177,10 @@ test.describe("Sessionless task switching", () => { panels: { chat: { id: "chat", contentComponent: "chat" } }, activeGroup: "g1", }; - window.sessionStorage.setItem(`kandev.dockview.layout.${sid}`, JSON.stringify(corrupt)); - }, sessionAId!); + window.sessionStorage.setItem(`kandev.dockview.env-layout.${envId}`, JSON.stringify(corrupt)); + }, sessionAEnvId!); - // Switch back to A — performSessionSwitch should drop the corrupt blob + // Switch back to A — performEnvSwitch should drop the corrupt blob // and rebuild the default instead of applying the zero-width layout. await session.clickTaskInSidebar("Layout Recovery Task A"); await expect(testPage).toHaveURL(new RegExp(`/t/${taskA.id}(?:\\?|$)`), { timeout: 10_000 }); diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts index 2f25cbabe2..b453e5eb00 100644 --- a/apps/web/hooks/use-task-removal.ts +++ b/apps/web/hooks/use-task-removal.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { KanbanState } from "@/lib/state/slices"; +import type { TaskSession } from "@/lib/types/http"; import { replaceTaskUrl } from "@/lib/links"; import { listTaskSessions } from "@/lib/api"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; @@ -23,6 +24,135 @@ type RemoveFromBoardOptions = { wasActiveSessionId?: string | null; }; +function cachedSessionsHaveEnvIds(sessions: TaskSession[]): boolean { + return sessions.length === 0 || sessions.every((session) => !!session.task_environment_id); +} + +async function loadTaskSessionsForTaskFromStore( + store: StoreApi, + taskId: string, +): Promise { + const state = store.getState(); + const cachedSessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + if (state.taskSessionsByTask.loadedByTaskId[taskId]) { + if (cachedSessionsHaveEnvIds(cachedSessions)) return cachedSessions; + } + if (state.taskSessionsByTask.loadingByTaskId[taskId]) { + return cachedSessions; + } + store.getState().setTaskSessionsLoading(taskId, true); + try { + const response = await listTaskSessions(taskId, { cache: "no-store" }); + store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []); + return response.sessions ?? []; + } catch (error) { + console.error("Failed to load task sessions:", error); + store.getState().setTaskSessionsForTask(taskId, []); + return []; + } finally { + store.getState().setTaskSessionsLoading(taskId, false); + } +} + +function removeTaskFromSnapshots(store: StoreApi, taskId: string): void { + const currentSnapshots = store.getState().kanbanMulti.snapshots; + for (const [wfId, snapshot] of Object.entries(currentSnapshots)) { + const hadTask = snapshot.tasks.some((t: KanbanState["tasks"][number]) => t.id === taskId); + if (hadTask) { + store.getState().setWorkflowSnapshot(wfId, { + ...snapshot, + tasks: snapshot.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), + }); + } + } + + const currentKanbanTasks = store.getState().kanban.tasks; + if (currentKanbanTasks.some((t: KanbanState["tasks"][number]) => t.id === taskId)) { + store.setState((state) => ({ + ...state, + kanban: { + ...state.kanban, + tasks: state.kanban.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), + }, + })); + } +} + +function collectRemainingTasks(store: StoreApi): KanbanState["tasks"] { + const allRemainingTasks: KanbanState["tasks"] = []; + for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) { + allRemainingTasks.push(...snapshot.tasks); + } + if (allRemainingTasks.length === 0) { + allRemainingTasks.push(...store.getState().kanban.tasks); + } + return allRemainingTasks; +} + +function switchToSessionForTask(params: { + store: StoreApi; + nextTask: KanbanState["tasks"][number]; + sessionId: string; + oldEnvId: string | null; + useLayoutSwitch: boolean; +}): void { + const { store, nextTask, sessionId, oldEnvId, useLayoutSwitch } = params; + store.getState().setActiveSession(nextTask.id, sessionId); + if (!useLayoutSwitch) return; + const newEnvId = store.getState().environmentIdBySessionId[sessionId] ?? null; + if (newEnvId) performLayoutSwitch(oldEnvId, newEnvId, sessionId); +} + +async function switchToNextTask(params: { + store: StoreApi; + nextTask: KanbanState["tasks"][number]; + oldEnvId: string | null; + useLayoutSwitch: boolean; + loadTaskSessionsForTask: (taskId: string) => Promise; +}): Promise { + const { store, nextTask, oldEnvId, useLayoutSwitch, loadTaskSessionsForTask } = params; + if (nextTask.primarySessionId) { + if (useLayoutSwitch && !store.getState().environmentIdBySessionId[nextTask.primarySessionId]) { + await loadTaskSessionsForTask(nextTask.id); + } + switchToSessionForTask({ + store, + nextTask, + sessionId: nextTask.primarySessionId, + oldEnvId, + useLayoutSwitch, + }); + replaceTaskUrl(nextTask.id); + return; + } + + const sessions = await loadTaskSessionsForTask(nextTask.id); + const sessionId = sessions[0]?.id ?? null; + if (sessionId) { + switchToSessionForTask({ store, nextTask, sessionId, oldEnvId, useLayoutSwitch }); + } else { + store.getState().setActiveTask(nextTask.id); + } + replaceTaskUrl(nextTask.id); +} + +function resolveOldEnvId(store: StoreApi, opts?: RemoveFromBoardOptions): string | null { + const oldSessionId = + opts?.wasActiveSessionId !== undefined + ? opts.wasActiveSessionId + : store.getState().tasks.activeSessionId; + return oldSessionId ? (store.getState().environmentIdBySessionId[oldSessionId] ?? null) : null; +} + +function resolveActiveTaskId( + store: StoreApi, + opts?: RemoveFromBoardOptions, +): string | null { + return opts?.wasActiveTaskId !== undefined + ? opts.wasActiveTaskId + : store.getState().tasks.activeTaskId; +} + /** * Hook that provides shared logic for removing a task from the kanban board * (after archive or delete) and switching to the next available task. @@ -31,83 +161,10 @@ type RemoveFromBoardOptions = { */ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOptions) { const loadTaskSessionsForTask = useCallback( - async (taskId: string) => { - const state = store.getState(); - if (state.taskSessionsByTask.loadedByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - if (state.taskSessionsByTask.loadingByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - store.getState().setTaskSessionsLoading(taskId, true); - try { - const response = await listTaskSessions(taskId, { cache: "no-store" }); - store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []); - return response.sessions ?? []; - } catch (error) { - console.error("Failed to load task sessions:", error); - store.getState().setTaskSessionsForTask(taskId, []); - return []; - } finally { - store.getState().setTaskSessionsLoading(taskId, false); - } - }, + (taskId: string) => loadTaskSessionsForTaskFromStore(store, taskId), [store], ); - /** Remove a task from both multi and single kanban snapshots. */ - const removeTaskFromSnapshots = useCallback( - (taskId: string) => { - const currentSnapshots = store.getState().kanbanMulti.snapshots; - for (const [wfId, snapshot] of Object.entries(currentSnapshots)) { - const hadTask = snapshot.tasks.some((t: KanbanState["tasks"][number]) => t.id === taskId); - if (hadTask) { - store.getState().setWorkflowSnapshot(wfId, { - ...snapshot, - tasks: snapshot.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), - }); - } - } - - const currentKanbanTasks = store.getState().kanban.tasks; - if (currentKanbanTasks.some((t: KanbanState["tasks"][number]) => t.id === taskId)) { - store.setState((state) => ({ - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), - }, - })); - } - }, - [store], - ); - - /** Switch to the next available task after removal. */ - const switchToNextTask = useCallback( - async (nextTask: KanbanState["tasks"][number], oldSessionId: string | null) => { - const { setActiveSession, setActiveTask } = store.getState(); - - if (nextTask.primarySessionId) { - setActiveSession(nextTask.id, nextTask.primarySessionId); - if (useLayoutSwitch) performLayoutSwitch(oldSessionId, nextTask.primarySessionId); - replaceTaskUrl(nextTask.id); - return; - } - - const sessions = await loadTaskSessionsForTask(nextTask.id); - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(nextTask.id, sessionId); - if (useLayoutSwitch) performLayoutSwitch(oldSessionId, sessionId); - } else { - setActiveTask(nextTask.id); - } - replaceTaskUrl(nextTask.id); - }, - [store, useLayoutSwitch, loadTaskSessionsForTask], - ); - /** * Remove a task from the kanban board state (both single and multi snapshots) * and switch to the next available task if the removed task was active. @@ -118,38 +175,30 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp */ const removeTaskFromBoard = useCallback( async (taskId: string, opts?: RemoveFromBoardOptions) => { - removeTaskFromSnapshots(taskId); - - // Collect remaining tasks across snapshots - const allRemainingTasks: KanbanState["tasks"] = []; - for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) { - allRemainingTasks.push(...snapshot.tasks); - } - if (allRemainingTasks.length === 0) { - allRemainingTasks.push(...store.getState().kanban.tasks); - } + removeTaskFromSnapshots(store, taskId); + const allRemainingTasks = collectRemainingTasks(store); // Use the caller-provided active task ID (captured before the async API // call) to avoid the race with the WS handler that may have already // cleared it. Fall back to the current store value for callers that // don't provide it (e.g. archive, which doesn't go through the API). - const activeTaskId = - opts?.wasActiveTaskId !== undefined - ? opts.wasActiveTaskId - : store.getState().tasks.activeTaskId; + const activeTaskId = resolveActiveTaskId(store, opts); if (activeTaskId !== taskId) return; - const oldSessionId = - opts?.wasActiveSessionId !== undefined - ? opts.wasActiveSessionId - : store.getState().tasks.activeSessionId; + const oldEnvId = resolveOldEnvId(store, opts); if (allRemainingTasks.length > 0) { - await switchToNextTask(allRemainingTasks[0], oldSessionId); + await switchToNextTask({ + store, + nextTask: allRemainingTasks[0], + oldEnvId, + useLayoutSwitch, + loadTaskSessionsForTask, + }); } else { window.location.href = "/"; } }, - [store, removeTaskFromSnapshots, switchToNextTask], + [store, useLayoutSwitch, loadTaskSessionsForTask], ); return { removeTaskFromBoard, loadTaskSessionsForTask }; diff --git a/apps/web/lib/layout/panel-portal-host.tsx b/apps/web/lib/layout/panel-portal-host.tsx index 5f5b01c73c..41ee0ed68c 100644 --- a/apps/web/lib/layout/panel-portal-host.tsx +++ b/apps/web/lib/layout/panel-portal-host.tsx @@ -67,12 +67,12 @@ export function PanelPortalHost({ renderPanel }: PanelPortalHostProps) { * Also updates the stored `api` and `params` on every mount, so the portal * content can read the latest dockview state. * - * @param sessionId — when provided, tags the portal as session-scoped so it - * gets cleaned up on session switch via `releaseBySession()`. + * @param envId — when provided, tags the portal as env-scoped so it + * gets cleaned up on env switch via `releaseByEnv()`. */ export function usePortalSlot( props: IDockviewPanelProps, - sessionId?: string, + envId?: string, ): React.RefObject { const containerRef = useRef(null); const panelId = props.api.id; @@ -83,7 +83,7 @@ export function usePortalSlot( const container = containerRef.current; if (!container) return; - const entry = panelPortalManager.acquire(panelId, component, params, props.api, sessionId); + const entry = panelPortalManager.acquire(panelId, component, params, props.api, envId); // Reparent the portal element into this panel's DOM slot. container.appendChild(entry.element); @@ -95,11 +95,11 @@ export function usePortalSlot( container.removeChild(entry.element); } }; - // sessionId in deps: when session changes, session-scoped panels re-acquire - // fresh portals (old ones were released by releaseBySession in the store action). - // Global panels pass sessionId=undefined so this is a no-op for them. + // envId in deps: when env changes, env-scoped panels re-acquire fresh + // portals (old ones were released by releaseByEnv in the store action). + // Global panels pass envId=undefined so this is a no-op for them. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [panelId, sessionId]); + }, [panelId, envId]); // Forward dockview's param updates into the portal manager so preview-tab // content (file-editor, diff-viewer, commit-detail) re-renders when the diff --git a/apps/web/lib/layout/panel-portal-manager.ts b/apps/web/lib/layout/panel-portal-manager.ts index 595c7a38a7..e62153c6b0 100644 --- a/apps/web/lib/layout/panel-portal-manager.ts +++ b/apps/web/lib/layout/panel-portal-manager.ts @@ -13,25 +13,26 @@ * – The actual React component tree is rendered into the portal element via * `createPortal`, so component state, effects, and DOM survive layout switches. * - * ## Session scoping + * ## Env scoping * - * Portals are either **global** or **session-scoped**: + * Portals are either **global** or **env-scoped**: * - * - **Global** (`sessionId` is `undefined`) — the portal persists across session + * - **Global** (`envId` is `undefined`) — the portal persists across env * switches. Used for panels that read `activeSessionId` reactively from the * store and automatically show the correct data for whichever session is active. * Examples: sidebar, chat, terminal, changes, files, plan. * Note: terminal, changes, and files use `useEnvironmentSessionId()` to stay * stable across same-environment session switches. * - * - **Session-scoped** (`sessionId` is set) — the portal is bound to the session - * that created it and is destroyed via `releaseBySession()` when the user - * switches away. Used for panels whose content is intrinsically tied to a - * specific session's runtime state (container processes, worktree files, etc.) - * and cannot simply re-read a store selector to switch context. - * Examples: file-editor, browser, vscode, commit-detail, diff-viewer, pr-detail. + * - **Env-scoped** (`envId` is set) — the portal is bound to the task + * environment that created it and is destroyed via `releaseByEnv()` when the + * user switches away to a different env. Used for panels whose content is + * intrinsically tied to a specific env's runtime state (container processes, + * worktree files, etc.) and cannot simply re-read a store selector to switch + * context. Examples: file-editor, browser, vscode, commit-detail, diff-viewer, + * pr-detail. * - * See `SESSION_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the + * See `ENV_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the * authoritative list and per-component rationale. */ @@ -46,8 +47,8 @@ export type PortalEntry = { params: Record; /** Latest dockview panel API handle — updated on each remount. */ api: DockviewPanelApi | null; - /** Session ID this portal is scoped to (undefined = global, persists across sessions). */ - sessionId?: string; + /** Task env ID this portal is scoped to (undefined = global, persists across envs). */ + envId?: string; }; type Listener = () => void; @@ -83,14 +84,14 @@ export class PanelPortalManager { component: string, params: Record, api: DockviewPanelApi, - sessionId?: string, + envId?: string, ): PortalEntry { let entry = this.entries.get(panelId); if (!entry) { const el = document.createElement("div"); el.style.display = "contents"; el.dataset.portalPanel = panelId; - entry = { element: el, component, params, api, sessionId }; + entry = { element: el, component, params, api, envId }; this.entries.set(panelId, entry); this.version++; this.notify(); @@ -114,11 +115,11 @@ export class PanelPortalManager { this.notify(); } - /** Release all portals scoped to a specific session. */ - releaseBySession(sessionId: string): void { + /** Release all portals scoped to a specific task env. */ + releaseByEnv(envId: string): void { const toRemove: string[] = []; for (const [panelId, entry] of this.entries) { - if (entry.sessionId === sessionId) { + if (entry.envId === envId) { toRemove.push(panelId); } } @@ -135,7 +136,7 @@ export class PanelPortalManager { /** * Release portals whose panel no longer exists in dockview. - * Call after fast-path session switches where `isRestoringLayout` blocked + * Call after fast-path env switches where `isRestoringLayout` blocked * the normal `onDidRemovePanel` cleanup. */ reconcile(livePanelIds: Set): void { diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 082d9e10d9..ba7277ea1d 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -317,17 +317,18 @@ export function setFilesPanelScrollPosition(sessionId: string, position: number) } // --- Dockview per-session layout (sessionStorage) --- -const DOCKVIEW_SESSION_LAYOUT_PREFIX = "kandev.dockview.layout."; +// Dockview layout is keyed by `taskEnvironmentId` so sessions sharing a task +// env reuse one layout (the env owns the workspace, terminals, files, etc.). +const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout."; /** - * Get the saved dockview layout for a session - * @param sessionId - The session ID - * @returns The serialized layout object, or null if not found + * Get the saved dockview layout for a task environment. + * Returns null if not found. */ -export function getSessionLayout(sessionId: string): object | null { +export function getEnvLayout(envId: string): object | null { if (typeof window === "undefined") return null; try { - const raw = window.sessionStorage.getItem(`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`); + const raw = window.sessionStorage.getItem(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`); if (!raw) return null; return JSON.parse(raw) as object; } catch { @@ -335,34 +336,27 @@ export function getSessionLayout(sessionId: string): object | null { } } -/** - * Save the dockview layout for a session - * @param sessionId - The session ID - * @param layout - The serialized layout object from api.toJSON() - */ -export function setSessionLayout(sessionId: string, layout: object): void { +/** Save the dockview layout for a task environment. */ +export function setEnvLayout(envId: string, layout: object): void { if (typeof window === "undefined") return; try { - window.sessionStorage.setItem( - `${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`, - JSON.stringify(layout), - ); + window.sessionStorage.setItem(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`, JSON.stringify(layout)); } catch { // Ignore write failures (storage full, blocked, etc.) } } -// --- Dockview per-session maximize state (sessionStorage) --- -const DOCKVIEW_SESSION_MAXIMIZE_PREFIX = "kandev.dockview.maximize."; +// --- Dockview per-env maximize state (sessionStorage) --- +const DOCKVIEW_ENV_MAXIMIZE_PREFIX = "kandev.dockview.env-maximize."; -export type SessionMaximizeState = { +export type EnvMaximizeState = { /** The pre-maximize (normal) layout to restore on exit-maximize. */ preMaximizeLayout: object; /** Native dockview JSON (api.toJSON()) for the maximized layout. */ maximizedDockviewJson: object; }; -function isSessionMaximizeState(value: unknown): value is SessionMaximizeState { +function isEnvMaximizeState(value: unknown): value is EnvMaximizeState { if (!value || typeof value !== "object") return false; const v = value as Record; return ( @@ -373,34 +367,31 @@ function isSessionMaximizeState(value: unknown): value is SessionMaximizeState { ); } -export function getSessionMaximizeState(sessionId: string): SessionMaximizeState | null { +export function getEnvMaximizeState(envId: string): EnvMaximizeState | null { if (typeof window === "undefined") return null; try { - const raw = window.sessionStorage.getItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`); + const raw = window.sessionStorage.getItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`); if (!raw) return null; const parsed: unknown = JSON.parse(raw); - return isSessionMaximizeState(parsed) ? parsed : null; + return isEnvMaximizeState(parsed) ? parsed : null; } catch { return null; } } -export function setSessionMaximizeState(sessionId: string, state: SessionMaximizeState): void { +export function setEnvMaximizeState(envId: string, state: EnvMaximizeState): void { if (typeof window === "undefined") return; try { - window.sessionStorage.setItem( - `${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`, - JSON.stringify(state), - ); + window.sessionStorage.setItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`, JSON.stringify(state)); } catch { // Ignore write failures } } -export function removeSessionMaximizeState(sessionId: string): void { +export function removeEnvMaximizeState(envId: string): void { if (typeof window === "undefined") return; try { - window.sessionStorage.removeItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`); + window.sessionStorage.removeItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`); } catch { // Ignore } @@ -626,10 +617,14 @@ export function setChatInputHeight(sessionId: string, height: number): void { // --- Task storage cleanup --- /** - * Remove all session-scoped storage for a deleted task. + * Remove all session/env-scoped storage for a deleted task. * Call from task.deleted handler before the task is removed from state. */ -export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void { +export function cleanupTaskStorage( + taskId: string, + sessionIds: string[], + envIds: string[] = [], +): void { // Plan notification (localStorage, keyed per task inside a Record) setPlanLastSeen(taskId, null); @@ -639,9 +634,14 @@ export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void { setStoredCollapsedSubtaskParents(collapsed.filter((id) => id !== taskId)); } - // Session-keyed storage — clean all sessions belonging to the task + // Env-keyed storage — dockview layout + maximize live under task envs. + for (const envId of envIds) { + removeEnvMaximizeState(envId); + removeSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`); + } + + // Session-keyed storage — drafts, files panel state, scroll, etc. for (const sessionId of sessionIds) { - removeSessionMaximizeState(sessionId); removeSessionStorage(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); @@ -651,7 +651,6 @@ export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void { removeSessionStorage(`${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`); removeSessionStorage(`${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`); removeSessionStorage(`${FILES_PANEL_KEYS.SCROLL}.${sessionId}`); - removeSessionStorage(`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`); removeSessionStorage(`${OPEN_FILES_KEY}.${sessionId}`); removeSessionStorage(`${ACTIVE_TAB_KEY}.${sessionId}`); removeSessionStorage(`kandev.contextFiles.${sessionId}`); diff --git a/apps/web/lib/state/dockview-env-switch-action.test.ts b/apps/web/lib/state/dockview-env-switch-action.test.ts new file mode 100644 index 0000000000..fe0c08e16e --- /dev/null +++ b/apps/web/lib/state/dockview-env-switch-action.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { DockviewApi } from "dockview-react"; +import { useDockviewStore } from "./dockview-store"; + +vi.mock("@/lib/local-storage", () => ({ + getEnvLayout: vi.fn(() => null), + setEnvLayout: vi.fn(), + getEnvMaximizeState: vi.fn(() => null), + setEnvMaximizeState: vi.fn(), + removeEnvMaximizeState: vi.fn(), +})); + +vi.mock("@/lib/layout/panel-portal-manager", () => ({ + panelPortalManager: { + releaseByEnv: vi.fn(), + reconcile: vi.fn(), + }, +})); + +import { setEnvLayout } from "@/lib/local-storage"; +import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; + +function makeMockApi(): DockviewApi { + return { + width: 800, + height: 600, + panels: [], + groups: [], + fromJSON: vi.fn(), + toJSON: vi.fn(() => ({})), + layout: vi.fn(), + activeGroup: null, + onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })), + getPanel: vi.fn(() => null), + addPanel: vi.fn(), + hasMaximizedGroup: vi.fn(() => false), + } as unknown as DockviewApi; +} + +describe("switchEnvLayout — root fix for terminal/layout swapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + useDockviewStore.setState({ + api: null, + currentLayoutEnvId: null, + preMaximizeLayout: null, + maximizedGroupId: null, + isRestoringLayout: false, + }); + }); + + it("no-ops when switching between sessions of the same env", () => { + const api = makeMockApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-shared" }); + + useDockviewStore.getState().switchEnvLayout("env-shared", "env-shared", "session-B"); + + // Same env = no layout rebuild + no portal release. This is the entire + // point of env-keyed layouts: terminals + panels stay put. + expect(api.fromJSON).not.toHaveBeenCalled(); + expect(panelPortalManager.releaseByEnv).not.toHaveBeenCalled(); + expect(setEnvLayout).not.toHaveBeenCalled(); + }); + + it("saves outgoing env + releases its portals when switching to a new env", () => { + const api = makeMockApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: "env-old" }); + + useDockviewStore.getState().switchEnvLayout("env-old", "env-new", "session-X"); + + expect(setEnvLayout).toHaveBeenCalledWith("env-old", expect.anything()); + expect(panelPortalManager.releaseByEnv).toHaveBeenCalledWith("env-old"); + expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-new"); + }); + + it("first adoption (no previous env) just records the new env", () => { + const api = makeMockApi(); + useDockviewStore.setState({ api, currentLayoutEnvId: null }); + + useDockviewStore.getState().switchEnvLayout(null, "env-first", "session-Y"); + + // First adoption keeps existing layout, no portal release on the + // (empty) outgoing env. + expect(panelPortalManager.releaseByEnv).not.toHaveBeenCalled(); + expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-first"); + }); + + it("does nothing when api is unset", () => { + useDockviewStore.setState({ api: null }); + useDockviewStore.getState().switchEnvLayout("env-a", "env-b", null); + expect(setEnvLayout).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/state/dockview-session-switch.test.ts b/apps/web/lib/state/dockview-env-switch.test.ts similarity index 61% rename from apps/web/lib/state/dockview-session-switch.test.ts rename to apps/web/lib/state/dockview-env-switch.test.ts index b0c19f4ec7..168b04bbb0 100644 --- a/apps/web/lib/state/dockview-session-switch.test.ts +++ b/apps/web/lib/state/dockview-env-switch.test.ts @@ -1,9 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { performSessionSwitch, type SessionSwitchParams } from "./dockview-session-switch"; +import { performEnvSwitch, type EnvSwitchParams } from "./dockview-env-switch"; -// Mock dependencies vi.mock("@/lib/local-storage", () => ({ - getSessionLayout: vi.fn(() => null), + getEnvLayout: vi.fn(() => null), })); vi.mock("./dockview-layout-builders", () => ({ @@ -21,7 +20,7 @@ vi.mock("./layout-manager", () => ({ layoutStructuresMatch: vi.fn(() => false), })); -import { getSessionLayout } from "@/lib/local-storage"; +import { getEnvLayout } from "@/lib/local-storage"; import { layoutStructuresMatch, savedLayoutMatchesLive } from "./layout-manager"; function makeMockApi() { @@ -31,10 +30,9 @@ function makeMockApi() { fromJSON: vi.fn(), getPanel: vi.fn(() => null), addPanel: vi.fn(), - } as unknown as SessionSwitchParams["api"]; + } as unknown as EnvSwitchParams["api"]; } -/** Build a SerializedDockview-shaped fixture that passes isLayoutShapeHealthy. */ function makeHealthyLayoutWith(extraPanels: Record) { return { grid: { @@ -52,14 +50,15 @@ function makeHealthyLayoutWith(extraPanels: Record; + } as unknown as ReturnType; } -function makeParams(overrides?: Partial): SessionSwitchParams { +function makeParams(overrides?: Partial): EnvSwitchParams { return { api: makeMockApi(), - oldSessionId: "old-session", - newSessionId: "new-session", + oldEnvId: "old-env", + newEnvId: "new-env", + activeSessionId: "new-session", safeWidth: 800, safeHeight: 600, buildDefault: vi.fn(), @@ -68,7 +67,7 @@ function makeParams(overrides?: Partial): SessionSwitchPara }; } -describe("performSessionSwitch", () => { +describe("performEnvSwitch", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -77,17 +76,17 @@ describe("performSessionSwitch", () => { vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true); const params = makeParams(); - performSessionSwitch(params); + performEnvSwitch(params); expect(params.api.layout).toHaveBeenCalledWith(800, 600); }); it("calls api.layout on the fast path when saved layout matches", () => { - vi.mocked(getSessionLayout).mockReturnValueOnce(makeHealthyLayoutWith({})); + vi.mocked(getEnvLayout).mockReturnValueOnce(makeHealthyLayoutWith({})); vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true); const params = makeParams(); - performSessionSwitch(params); + performEnvSwitch(params); expect(params.api.layout).toHaveBeenCalledWith(800, 600); expect(params.api.fromJSON).not.toHaveBeenCalled(); @@ -97,7 +96,7 @@ describe("performSessionSwitch", () => { vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true); const params = makeParams(); - performSessionSwitch(params); + performEnvSwitch(params); expect(params.api.addPanel).toHaveBeenCalledWith( expect.objectContaining({ @@ -115,45 +114,34 @@ describe("performSessionSwitch", () => { api: { ...makeMockApi(), getPanel: vi.fn((id: string) => (id === "session:new-session" ? panel : null)), - } as unknown as SessionSwitchParams["api"], + } as unknown as EnvSwitchParams["api"], }); - performSessionSwitch(params); + performEnvSwitch(params); expect(params.api.addPanel).not.toHaveBeenCalled(); }); - it("skips fast path when saved layout has ephemeral panels (file-editor)", () => { - const savedLayout = makeHealthyLayoutWith({ - "preview:file-editor": { contentComponent: "file-editor" }, - }); - vi.mocked(getSessionLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout); - vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true); - const params = makeParams(); + it.each(["file-editor", "browser", "vscode", "commit-detail", "diff-viewer", "pr-detail"])( + "skips fast path when saved layout has ephemeral panels (%s)", + (contentComponent) => { + const savedLayout = makeHealthyLayoutWith({ + [`preview:${contentComponent}`]: { contentComponent }, + }); + vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout); + vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true); + const params = makeParams(); - performSessionSwitch(params); + performEnvSwitch(params); - // Should use fromJSON (slow path) instead of fast path - expect(params.api.fromJSON).toHaveBeenCalled(); - }); - - it("skips fast path when saved layout has ephemeral panels (diff-viewer)", () => { - const savedLayout = makeHealthyLayoutWith({ - "preview:file-diff": { contentComponent: "diff-viewer" }, - }); - vi.mocked(getSessionLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout); - vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true); - const params = makeParams(); - - performSessionSwitch(params); - - expect(params.api.fromJSON).toHaveBeenCalled(); - }); + expect(params.api.fromJSON).toHaveBeenCalled(); + }, + ); it("calls api.layout on the slow path (buildDefault fallback)", () => { const params = makeParams(); - performSessionSwitch(params); + performEnvSwitch(params); expect(params.api.layout).toHaveBeenCalledWith(800, 600); expect(params.buildDefault).toHaveBeenCalledWith(params.api); diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts new file mode 100644 index 0000000000..fdac03f2b4 --- /dev/null +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -0,0 +1,167 @@ +/** + * Env switch logic for dockview layout management. + * + * Layouts are keyed by `taskEnvironmentId`. Sessions sharing an env reuse the + * same layout, so switching between same-env sessions is a no-op at the + * layout level (handled by the caller). Cross-env switches use either a + * "fast path" (skip fromJSON when the structure already matches) or a + * "slow path" (full layout rebuild via fromJSON). + */ +import type { DockviewApi, SerializedDockview } from "dockview-react"; +import { getEnvLayout } from "@/lib/local-storage"; +import { applyLayoutFixups } from "./dockview-layout-builders"; +import { isLayoutShapeHealthy } from "./dockview-layout-health"; +import { fromDockviewApi, savedLayoutMatchesLive, layoutStructuresMatch } from "./layout-manager"; +import type { LayoutState, LayoutGroupIds } from "./layout-manager"; + +const EPHEMERAL_COMPONENTS = new Set([ + "file-editor", + "browser", + "vscode", + "commit-detail", + "diff-viewer", + "pr-detail", +]); + +/** Fetch the saved layout for an env, dropping it if its shape is corrupted. */ +function getHealthyEnvLayout(envId: string): object | null { + const saved = getEnvLayout(envId); + if (!saved) return null; + return isLayoutShapeHealthy(saved) ? saved : null; +} + +/** Check whether a serialized dockview layout contains ephemeral panels. */ +function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const panels = (serialized as any).panels as + | Record + | undefined; + if (!panels) return false; + return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? "")); +} + +export type EnvSwitchParams = { + api: DockviewApi; + oldEnvId: string | null; + newEnvId: string; + /** Active session for the incoming env — used to keep the right session chat tab. */ + activeSessionId: string | null; + safeWidth: number; + safeHeight: number; + buildDefault: (api: DockviewApi) => void; + getDefaultLayout: () => LayoutState; +}; + +/** + * Remove ephemeral panels (file-editors, diffs, commit-details) from the + * live layout. These are env-scoped panels that shouldn't carry over. + * + * When `keepSessionId` is provided, session chat panels whose ID does not + * match `session:{keepSessionId}` are also removed. This handles cross-env + * (cross-task) switches where the fast path is taken: without this, session + * tabs from the old env's task remain visible alongside the new task's tab. + */ +function removeEphemeralPanels(api: DockviewApi, keepSessionId: string | null): void { + const toRemove = api.panels.filter((p) => { + const comp = p.api.component; + if (EPHEMERAL_COMPONENTS.has(comp)) { + return true; + } + if ( + keepSessionId !== null && + comp === "chat" && + p.id.startsWith("session:") && + p.id !== `session:${keepSessionId}` + ) { + return true; + } + return false; + }); + for (const p of toRemove) { + try { + p.api.close(); + } catch { + /* panel may already be gone */ + } + } +} + +/** + * Fast path: check if we can skip `api.fromJSON()` because the layout + * structure hasn't changed. Returns group IDs if the fast path was taken, + * or null if a full rebuild is needed. + */ +function tryFastEnvSwitch(params: EnvSwitchParams): LayoutGroupIds | null { + const { api, newEnvId, activeSessionId, getDefaultLayout } = params; + const currentLayout = fromDockviewApi(api); + const saved = getHealthyEnvLayout(newEnvId); + + let structuresMatch = false; + if (saved) { + structuresMatch = savedLayoutMatchesLive(currentLayout, saved as SerializedDockview); + } else { + structuresMatch = layoutStructuresMatch(currentLayout, getDefaultLayout()); + } + + if (!structuresMatch) return null; + + if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null; + + const outgoingSessionPanel = api.panels.find( + (p) => p.id.startsWith("session:") || p.api.component === "chat", + ); + const outgoingGroupId = outgoingSessionPanel?.group?.id; + + removeEphemeralPanels(api, activeSessionId); + + if (activeSessionId && !api.getPanel(`session:${activeSessionId}`)) { + const sidebarPanel = api.getPanel("sidebar"); + let position: import("dockview-react").AddPanelOptions["position"]; + if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) { + position = { referenceGroup: outgoingGroupId }; + } else if (sidebarPanel) { + position = { direction: "right" as const, referencePanel: "sidebar" }; + } + api.addPanel({ + id: `session:${activeSessionId}`, + component: "chat", + tabComponent: "sessionTab", + title: "Agent", + params: { sessionId: activeSessionId }, + position, + }); + } + + api.layout(params.safeWidth, params.safeHeight); + return applyLayoutFixups(api); +} + +/** + * Switch the dockview layout between task environments. + * + * Uses a fast path when layouts are structurally identical (common case), + * falling back to a full `api.fromJSON()` rebuild when they differ. + * + * The caller is responsible for saving the old env's layout and releasing + * env-scoped portals before calling this function. + */ +export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds { + const { api, newEnvId, safeWidth, safeHeight, buildDefault } = params; + + const fastResult = tryFastEnvSwitch(params); + if (fastResult) return fastResult; + + const saved = getHealthyEnvLayout(newEnvId); + if (saved) { + try { + api.fromJSON(saved as SerializedDockview); + api.layout(safeWidth, safeHeight); + return applyLayoutFixups(api); + } catch { + /* fall through */ + } + } + buildDefault(api); + api.layout(safeWidth, safeHeight); + return applyLayoutFixups(api); +} diff --git a/apps/web/lib/state/dockview-session-switch.ts b/apps/web/lib/state/dockview-session-switch.ts deleted file mode 100644 index 707c01e68a..0000000000 --- a/apps/web/lib/state/dockview-session-switch.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Session switch logic for dockview layout management. - * - * Handles both a "fast path" (skip fromJSON when layout structure matches) - * and a "slow path" (full layout rebuild via fromJSON). - */ -import type { DockviewApi, SerializedDockview } from "dockview-react"; -import { getSessionLayout } from "@/lib/local-storage"; -import { applyLayoutFixups } from "./dockview-layout-builders"; -import { isLayoutShapeHealthy } from "./dockview-layout-health"; -import { fromDockviewApi, savedLayoutMatchesLive, layoutStructuresMatch } from "./layout-manager"; -import type { LayoutState, LayoutGroupIds } from "./layout-manager"; - -const EPHEMERAL_COMPONENTS = new Set(["file-editor", "diff-viewer", "commit-detail"]); - -/** Fetch the saved layout for a session, dropping it if its shape is corrupted. */ -function getHealthySessionLayout(sessionId: string): object | null { - const saved = getSessionLayout(sessionId); - if (!saved) return null; - return isLayoutShapeHealthy(saved) ? saved : null; -} - -/** Check whether a serialized dockview layout contains ephemeral panels. */ -function savedLayoutHasEphemeralPanels(serialized: SerializedDockview): boolean { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const panels = (serialized as any).panels as - | Record - | undefined; - if (!panels) return false; - return Object.values(panels).some((p) => EPHEMERAL_COMPONENTS.has(p.contentComponent ?? "")); -} - -export type SessionSwitchParams = { - api: DockviewApi; - oldSessionId: string | null; - newSessionId: string; - safeWidth: number; - safeHeight: number; - buildDefault: (api: DockviewApi) => void; - getDefaultLayout: () => LayoutState; -}; - -/** - * Remove ephemeral panels (file-editors, diffs, commit-details) from the - * live layout. These are session-specific panels that shouldn't carry over. - * - * When `keepSessionId` is provided, session chat panels whose ID does not - * match `session:{keepSessionId}` are also removed. This handles cross-task - * switches where the fast path is taken: without this, session tabs from the - * old task remain visible alongside the new task's tab. - */ -function removeEphemeralPanels(api: DockviewApi, keepSessionId?: string): void { - const toRemove = api.panels.filter((p) => { - const comp = p.api.component; - if (comp === "file-editor" || comp === "diff-viewer" || comp === "commit-detail") { - return true; - } - // Remove session chat tabs that belong to a different session. - // This covers cross-task switches where the layout structures match and - // the fast path is taken — old task's session tabs must not bleed through. - if ( - keepSessionId !== undefined && - comp === "chat" && - p.id.startsWith("session:") && - p.id !== `session:${keepSessionId}` - ) { - return true; - } - return false; - }); - for (const p of toRemove) { - try { - p.api.close(); - } catch { - /* panel may already be gone */ - } - } -} - -/** - * Fast path: check if we can skip `api.fromJSON()` because the layout - * structure hasn't changed. Returns group IDs if the fast path was taken, - * or null if a full rebuild is needed. - */ -function tryFastSessionSwitch(params: SessionSwitchParams): LayoutGroupIds | null { - const { api, newSessionId, getDefaultLayout } = params; - const currentLayout = fromDockviewApi(api); - const saved = getHealthySessionLayout(newSessionId); - - let structuresMatch = false; - if (saved) { - // Compare live layout against saved layout by structural component set - structuresMatch = savedLayoutMatchesLive(currentLayout, saved as SerializedDockview); - } else { - // No saved layout — target is the default; compare LayoutState structures - structuresMatch = layoutStructuresMatch(currentLayout, getDefaultLayout()); - } - - if (!structuresMatch) return null; - - // If the saved layout contains ephemeral panels (file-editors, diffs, - // commit-details), fall through to the slow path so that `api.fromJSON()` - // restores them. The fast path skips fromJSON and removeEphemeralPanels - // would discard the current ones without restoring the target session's. - if (saved && savedLayoutHasEphemeralPanels(saved as SerializedDockview)) return null; - - // Capture the outgoing session tab's group BEFORE removing it so we can - // place the new session tab in the same group. Without this, the lookup - // after removeEphemeralPanels finds no chat/session panels and falls - // through to a sidebar split — putting the new tab in a new group while - // the PR panel stays in the old center group. - const outgoingSessionPanel = api.panels.find( - (p) => p.id.startsWith("session:") || p.api.component === "chat", - ); - const outgoingGroupId = outgoingSessionPanel?.group?.id; - - // Fast path: keep the grid structure, clean up ephemeral panels and any - // session chat tabs that belong to a different session (cross-task switch). - // Session-scoped portals (browser, vscode, etc.) will be re-acquired - // via usePortalSlot's sessionId dependency change. - removeEphemeralPanels(api, newSessionId); - - // Create the new session tab inline so there's no gap between removing the - // old tab and creating the new one. useAutoSessionTab will detect the panel - // already exists and skip creation. - if (!api.getPanel(`session:${newSessionId}`)) { - const sidebarPanel = api.getPanel("sidebar"); - let position: import("dockview-react").AddPanelOptions["position"]; - if (outgoingGroupId && api.groups.some((g) => g.id === outgoingGroupId)) { - position = { referenceGroup: outgoingGroupId }; - } else if (sidebarPanel) { - position = { direction: "right" as const, referencePanel: "sidebar" }; - } - api.addPanel({ - id: `session:${newSessionId}`, - component: "chat", - tabComponent: "sessionTab", - title: "Agent", - params: { sessionId: newSessionId }, - position, - }); - } - - api.layout(params.safeWidth, params.safeHeight); - return applyLayoutFixups(api); -} - -/** - * Switch the dockview layout between sessions. - * - * Uses a fast path when layouts are structurally identical (common case), - * falling back to a full `api.fromJSON()` rebuild when they differ. - * - * The caller is responsible for saving the old session layout and releasing - * session-scoped portals before calling this function. - */ -export function performSessionSwitch(params: SessionSwitchParams): LayoutGroupIds { - const { api, newSessionId, safeWidth, safeHeight, buildDefault } = params; - - // Try fast path: skip fromJSON when layout structure hasn't changed - const fastResult = tryFastSessionSwitch(params); - if (fastResult) return fastResult; - - // Slow path: full layout rebuild via fromJSON. Use the validating loader - // so a corrupted blob from a previous failed switch is dropped instead of - // applied verbatim (which would collapse the central group). - const saved = getHealthySessionLayout(newSessionId); - if (saved) { - try { - api.fromJSON(saved as SerializedDockview); - api.layout(safeWidth, safeHeight); - return applyLayoutFixups(api); - } catch { - /* fall through */ - } - } - buildDefault(api); - api.layout(safeWidth, safeHeight); - return applyLayoutFixups(api); -} diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 3faf65198f..21f97c8a20 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -2,10 +2,10 @@ import { create } from "zustand"; import type { DockviewApi, AddPanelOptions, SerializedDockview } from "dockview-react"; import { - setSessionLayout, - getSessionMaximizeState, - setSessionMaximizeState, - removeSessionMaximizeState, + setEnvLayout, + getEnvMaximizeState, + setEnvMaximizeState, + removeEnvMaximizeState, } from "@/lib/local-storage"; import { applyLayoutFixups, focusOrAddPanel } from "./dockview-layout-builders"; import { @@ -22,7 +22,7 @@ import { mergeCurrentPanelsIntoPreset, } from "./layout-manager"; import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager"; -import { performSessionSwitch } from "./dockview-session-switch"; +import { performEnvSwitch } from "./dockview-env-switch"; import { injectIntentPanels, applyActivePanelOverrides, @@ -129,8 +129,17 @@ type DockviewStore = { applyCustomLayout: (layout: SavedLayoutConfig) => void; captureCurrentLayout: () => Record; isRestoringLayout: boolean; - currentLayoutSessionId: string | null; - switchSessionLayout: (oldSessionId: string | null, newSessionId: string) => void; + /** ID of the task environment whose layout is currently rendered. Layouts are + * keyed by env so sessions sharing an env reuse one layout. */ + currentLayoutEnvId: string | null; + /** Switch the rendered layout to a new task environment. Same-env switches + * are a no-op (the layout already belongs to that env). `activeSessionId` + * is the session whose chat panel should be present in the new env. */ + switchEnvLayout: ( + oldEnvId: string | null, + newEnvId: string, + activeSessionId: string | null, + ) => void; deferredPanelActions: DeferredPanelAction[]; queuePanelAction: (action: DeferredPanelAction) => void; pinnedWidths: Map; @@ -146,9 +155,6 @@ type DockviewStore = { maximizedGroupId: string | null; maximizeGroup: (groupId: string) => void; exitMaximizedLayout: () => void; - /** One-shot flag: skip the next layout switch for this specific session ID. - * Used when adding a panel within the same task to prevent a full rebuild. */ - _skipLayoutSwitchForSession: string | null; }; type StoreGet = () => DockviewStore; @@ -390,8 +396,8 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { } /** Restore a saved maximize state from sessionStorage onto the dockview API. */ -function restoreMaximizeFromStorage(api: DockviewApi, sessionId: string, set: StoreSet): boolean { - const saved = getSessionMaximizeState(sessionId); +function restoreMaximizeFromStorage(api: DockviewApi, envId: string, set: StoreSet): boolean { + const saved = getEnvMaximizeState(envId); if (!saved) return false; try { api.fromJSON(saved.maximizedDockviewJson as SerializedDockview); @@ -408,73 +414,63 @@ function restoreMaximizeFromStorage(api: DockviewApi, sessionId: string, set: St return true; } -/** Consume the one-shot skip flag. Returns true if the switch should be skipped. */ -function consumeSkipFlag(set: StoreSet, get: StoreGet, newSessionId: string): boolean { - const flag = get()._skipLayoutSwitchForSession; - if (!flag) return false; - set({ _skipLayoutSwitchForSession: null }); - if (flag === newSessionId) { - set({ currentLayoutSessionId: newSessionId }); - return true; - } - return false; -} - -/** Save the outgoing session's layout & maximize state, then release its portals. */ -function saveOutgoingSession( +/** Save the outgoing env's layout & maximize state, then release its portals. */ +function saveOutgoingEnv( api: DockviewApi, - oldSessionId: string | null, + oldEnvId: string | null, preMaximizeLayout: LayoutState | null, ): void { - if (!oldSessionId) return; + if (!oldEnvId) return; if (preMaximizeLayout) { - setSessionMaximizeState(oldSessionId, { + setEnvMaximizeState(oldEnvId, { preMaximizeLayout: preMaximizeLayout as unknown as object, maximizedDockviewJson: api.toJSON(), }); } else { - removeSessionMaximizeState(oldSessionId); + removeEnvMaximizeState(oldEnvId); } try { - setSessionLayout(oldSessionId, api.toJSON()); + setEnvLayout(oldEnvId, api.toJSON()); } catch { /* ignore */ } - panelPortalManager.releaseBySession(oldSessionId); + panelPortalManager.releaseByEnv(oldEnvId); } -function buildSessionSwitchAction(set: StoreSet, get: StoreGet) { - return (oldSessionId: string | null, newSessionId: string) => { - const { api, currentLayoutSessionId, preMaximizeLayout } = get(); +function buildEnvSwitchAction(set: StoreSet, get: StoreGet) { + return (oldEnvId: string | null, newEnvId: string, activeSessionId: string | null) => { + const { api, currentLayoutEnvId, preMaximizeLayout } = get(); if (!api) return; - if (consumeSkipFlag(set, get, newSessionId)) return; - if (currentLayoutSessionId === newSessionId) return; - // First session adoption — onReady already built the layout; just adopt it. - if (!oldSessionId && !currentLayoutSessionId) { - set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId }); - if (restoreMaximizeFromStorage(api, newSessionId, set)) return; - set({ isRestoringLayout: false, currentLayoutSessionId: newSessionId }); + // Same-env switch (e.g. between sessions of the same task) is a no-op. + // The layout, terminals, and env-scoped portals already belong to this env. + if (currentLayoutEnvId === newEnvId) return; + // First adoption — onReady already built the layout; just adopt it. + if (!oldEnvId && !currentLayoutEnvId) { + set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId }); + if (restoreMaximizeFromStorage(api, newEnvId, set)) return; + set({ isRestoringLayout: false, currentLayoutEnvId: newEnvId }); try { - setSessionLayout(newSessionId, api.toJSON()); + setEnvLayout(newEnvId, api.toJSON()); } catch { /* ignore */ } return; } - // When oldSessionId is null but there is a live layout session (e.g. the - // useSessionSwitchCleanup hook fires after passing through a null state), - // fall back to currentLayoutSessionId so we correctly save and release the - // outgoing session rather than silently skipping it. - const effectiveOld = oldSessionId ?? currentLayoutSessionId; - saveOutgoingSession(api, effectiveOld, preMaximizeLayout); + // When oldEnvId is null but there is a live layout env (e.g. the + // useEnvSwitchCleanup hook fires after passing through a null state), + // fall back to currentLayoutEnvId so we correctly save and release the + // outgoing env rather than silently skipping it. + const effectiveOld = oldEnvId ?? currentLayoutEnvId; + saveOutgoingEnv(api, effectiveOld, preMaximizeLayout); set({ preMaximizeLayout: null, maximizedGroupId: null }); - set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId }); + set({ isRestoringLayout: true, currentLayoutEnvId: newEnvId }); try { - if (restoreMaximizeFromStorage(api, newSessionId, set)) return; - const ids = performSessionSwitch({ + if (restoreMaximizeFromStorage(api, newEnvId, set)) return; + const ids = performEnvSwitch({ api, - oldSessionId: effectiveOld, - newSessionId, + oldEnvId: effectiveOld, + newEnvId, + activeSessionId, safeWidth: api.width, safeHeight: api.height, buildDefault: (a) => get().buildDefaultLayout(a), @@ -492,7 +488,7 @@ function buildSessionSwitchAction(set: StoreSet, get: StoreGet) { function buildMaximizeActions(set: StoreSet, get: StoreGet) { return { maximizeGroup: (groupId: string) => { - const { api, preMaximizeLayout, currentLayoutSessionId } = get(); + const { api, preMaximizeLayout, currentLayoutEnvId } = get(); if (!api) return; if (preMaximizeLayout) { get().exitMaximizedLayout(); @@ -529,9 +525,8 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { applyLayoutAndSet(api, maximizedLayout, liveWidths, set); requestAnimationFrame(() => { api.layout(safeWidth, safeHeight); - // Persist to sessionStorage so maximize survives page refresh - if (currentLayoutSessionId) { - setSessionMaximizeState(currentLayoutSessionId, { + if (currentLayoutEnvId) { + setEnvMaximizeState(currentLayoutEnvId, { preMaximizeLayout: current as unknown as object, maximizedDockviewJson: api.toJSON(), }); @@ -540,15 +535,15 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) { }); }, exitMaximizedLayout: () => { - const { api, preMaximizeLayout, currentLayoutSessionId } = get(); + const { api, preMaximizeLayout, currentLayoutEnvId } = get(); if (!api || !preMaximizeLayout) return; preserveChatScrollDuringLayout(); const safeWidth = api.width; const safeHeight = api.height; const liveWidths = get().pinnedWidths; set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null }); - if (currentLayoutSessionId) { - removeSessionMaximizeState(currentLayoutSessionId); + if (currentLayoutEnvId) { + removeEnvMaximizeState(currentLayoutEnvId); } applyLayoutAndSet(api, preMaximizeLayout, liveWidths, set); requestAnimationFrame(() => { @@ -655,13 +650,13 @@ export const useDockviewStore = create((set, get) => ({ ...buildVisibilityActions(set, get), ...buildPresetActions(set, get), isRestoringLayout: false, - currentLayoutSessionId: null, + currentLayoutEnvId: null, deferredPanelActions: [], queuePanelAction: (action) => set((prev) => ({ deferredPanelActions: [...prev.deferredPanelActions, action], })), - switchSessionLayout: buildSessionSwitchAction(set, get), + switchEnvLayout: buildEnvSwitchAction(set, get), buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName), resetLayout: () => { const { api } = get(); @@ -671,33 +666,43 @@ export const useDockviewStore = create((set, get) => ({ setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }), preMaximizeLayout: null, maximizedGroupId: null, - _skipLayoutSwitchForSession: null, ...buildMaximizeActions(set, get), ...buildPanelActions(set, get), ...buildExtraPanelActions(get), })); -/** Perform a layout switch between sessions. */ -export function performLayoutSwitch(oldSessionId: string | null, newSessionId: string): void { - useDockviewStore.getState().switchSessionLayout(oldSessionId, newSessionId); +/** + * Perform a layout switch between task environments. Same-env (e.g. between + * sessions of the same task) is a no-op — terminals + layout stay put. + * + * `activeSessionId` is the session whose chat panel should be present in the + * resulting layout. It can differ across sessions of the same env, but layout + * reuse means we just ensure the right session: chat panel is visible. + */ +export function performLayoutSwitch( + oldEnvId: string | null, + newEnvId: string, + activeSessionId: string | null, +): void { + useDockviewStore.getState().switchEnvLayout(oldEnvId, newEnvId, activeSessionId); } /** * Release the dockview to a clean default layout — used when selecting a task * that has no session (and prepare failed to launch one). Without this the - * dockview keeps the outgoing session's panels live but disconnected from any + * dockview keeps the outgoing env's panels live but disconnected from any * active session, and the corrupted state can be persisted on the next save. */ -export function releaseLayoutToDefault(oldSessionId: string | null): void { - const { api, currentLayoutSessionId, preMaximizeLayout, buildDefaultLayout } = +export function releaseLayoutToDefault(oldEnvId: string | null): void { + const { api, currentLayoutEnvId, preMaximizeLayout, buildDefaultLayout } = useDockviewStore.getState(); if (!api) return; - const effectiveOld = oldSessionId ?? currentLayoutSessionId; - saveOutgoingSession(api, effectiveOld, preMaximizeLayout); + const effectiveOld = oldEnvId ?? currentLayoutEnvId; + saveOutgoingEnv(api, effectiveOld, preMaximizeLayout); useDockviewStore.setState({ preMaximizeLayout: null, maximizedGroupId: null, - currentLayoutSessionId: null, + currentLayoutEnvId: null, }); buildDefaultLayout(api); } diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 63ed3b5a37..682cba1310 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -162,7 +162,14 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { if (task?.primarySessionId && !sessionIds.includes(task.primarySessionId)) { sessionIds.push(task.primarySessionId); } - cleanupTaskStorage(deletedId, sessionIds); + const envIds = Array.from( + new Set( + sessionIds + .map((sid) => currentState.environmentIdBySessionId[sid]) + .filter((eid): eid is string => Boolean(eid)), + ), + ); + cleanupTaskStorage(deletedId, sessionIds, envIds); for (const sid of sessionIds) { useContextFilesStore.getState().clearSession(sid); }