feat: key terminals + dockview layout to TaskEnvironment (#755)

* feat(backend): key user shells by TaskEnvironmentID

Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.

- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
  / executor.LaunchAgentRequest, propagated through ToAgentExecution and the
  orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
  it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
  are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
  ResolveScopeKey before calling the runner.

* feat(web): key dockview layout + portals to TaskEnvironmentID

Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.

- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
  -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
  -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
  -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
  Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
  structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
  ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
  sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
  layout/maximize cleanup.

* test: add coverage for env-keyed terminals + layout

Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.

* fix: address env layout PR feedback

* test: fix flaky handshake auth check

* fix: address env scope review findings
This commit is contained in:
José Almeida
2026-05-01 11:00:33 +01:00
committed by GitHub
parent 7b5b3f329d
commit 45ee2ff48f
42 changed files with 1388 additions and 812 deletions
+1
View File
@@ -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
@@ -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
@@ -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,
@@ -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),
@@ -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()
@@ -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
@@ -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),
+38 -35
View File
@@ -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")
@@ -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")
}
@@ -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))
}
@@ -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")
}
}
}
@@ -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),
@@ -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
@@ -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,
@@ -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
}
@@ -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) {
@@ -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
@@ -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)
}
@@ -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();
@@ -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 <div ref={containerRef} className="h-full w-full overflow-hidden" />;
}
@@ -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<string | null | undefined>(undefined);
const prevTaskRef = useRef<string | null>(null);
const appStore = useAppStoreApi();
function useEnvSwitchCleanup(effectiveSessionId: string | null, effectiveEnvId: string | null) {
const prevEnvRef = useRef<string | null | undefined>(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<string | null>(effectiveSessionId);
const effectiveEnvId = useAppStore((state) =>
effectiveSessionId ? (state.environmentIdBySessionId[effectiveSessionId] ?? null) : null,
);
const envIdRef = useRef<string | null>(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"
/>
</div>
<BottomTerminalPanel sessionId={effectiveSessionId} />
<BottomTerminalPanel />
<PanelPortalHost renderPanel={renderPanel} />
{effectiveSessionId && (
<ReviewDialog
@@ -367,11 +367,14 @@ function SidebarRightActions() {
const handleTaskCreated = useCallback(
(task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => {
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);
},
@@ -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<string>): 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<string>,
): 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) {
@@ -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<ReturnType<typeof setTimeout> | null>,
sessionIdRef: React.MutableRefObject<string | null>,
envIdRef: React.MutableRefObject<string | null>,
): 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);
});
@@ -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<AppState>) {
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);
}
}
@@ -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);
@@ -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,
],
@@ -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],
@@ -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],
+137 -5
View File
@@ -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<AppState>,
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<AppState>,
switchToSession: SwitchToSessionFn,
setPreparingTaskId: (id: string | null) => void,
): Promise<boolean> {
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<AppState>;
switchToSession: SwitchToSessionFn;
loadTaskSessionsForTask: (taskId: string) => Promise<TaskSession[]>;
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,
});
});
}
@@ -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<typeof useAppStoreApi>;
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<boolean> {
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],
@@ -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();
});
});
@@ -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 });
+143 -94
View File
@@ -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<AppState>,
taskId: string,
): Promise<TaskSession[]> {
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<AppState>, 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<AppState>): 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<AppState>;
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<AppState>;
nextTask: KanbanState["tasks"][number];
oldEnvId: string | null;
useLayoutSwitch: boolean;
loadTaskSessionsForTask: (taskId: string) => Promise<TaskSession[]>;
}): Promise<void> {
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<AppState>, 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<AppState>,
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 };
+8 -8
View File
@@ -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<HTMLDivElement | null> {
const containerRef = useRef<HTMLDivElement | null>(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
+19 -18
View File
@@ -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<string, unknown>;
/** 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<string, unknown>,
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<string>): void {
+34 -35
View File
@@ -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<string, unknown>;
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}`);
@@ -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();
});
});
@@ -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<string, { contentComponent: string }>) {
return {
grid: {
@@ -52,14 +50,15 @@ function makeHealthyLayoutWith(extraPanels: Record<string, { contentComponent: s
...extraPanels,
},
activeGroup: "g1",
} as unknown as ReturnType<typeof getSessionLayout>;
} as unknown as ReturnType<typeof getEnvLayout>;
}
function makeParams(overrides?: Partial<SessionSwitchParams>): SessionSwitchParams {
function makeParams(overrides?: Partial<EnvSwitchParams>): 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<SessionSwitchParams>): 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);
+167
View File
@@ -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<string, { contentComponent?: string }>
| 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);
}
@@ -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<string, { contentComponent?: string }>
| 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);
}
+78 -73
View File
@@ -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<string, unknown>;
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<string, number>;
@@ -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<DockviewStore>((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<DockviewStore>((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);
}
+8 -1
View File
@@ -162,7 +162,14 @@ export function registerTasksHandlers(store: StoreApi<AppState>): 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);
}