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);
}