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:
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+30
-42
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user