diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 9b781185f6..0737a985f3 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/scripts" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" @@ -23,6 +24,23 @@ import ( userservice "github.com/kandev/kandev/internal/user/service" ) +// scriptServiceAdapter adapts the task service to scripts.ScriptService. +type scriptServiceAdapter struct { + taskSvc *taskservice.Service +} + +func (a *scriptServiceAdapter) GetRepositoryScript(ctx context.Context, id string) (*scripts.RepositoryScript, error) { + script, err := a.taskSvc.GetRepositoryScript(ctx, id) + if err != nil { + return nil, err + } + return &scripts.RepositoryScript{ + ID: script.ID, + Name: script.Name, + Command: script.Command, + }, nil +} + func provideGateway( ctx context.Context, log *logger.Logger, @@ -42,8 +60,9 @@ func provideGateway( _ = cleanup // Enable dedicated terminal WebSocket for passthrough mode + scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc} if lifecycleMgr != nil { - gateway.SetLifecycleManager(lifecycleMgr) + gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc) } orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log) @@ -57,7 +76,7 @@ func provideGateway( workspaceFileHandlers := agenthandlers.NewWorkspaceFileHandlers(lifecycleMgr, log) workspaceFileHandlers.RegisterHandlers(gateway.Dispatcher) - shellHandlers := agenthandlers.NewShellHandlers(lifecycleMgr, log) + shellHandlers := agenthandlers.NewShellHandlers(lifecycleMgr, scriptSvc, log) shellHandlers.RegisterHandlers(gateway.Dispatcher) gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, log) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 75cc876dba..0d90cecea3 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -30,6 +30,7 @@ import ( // Agent Manager packages "github.com/kandev/kandev/internal/agent/docker" + agenthandlers "github.com/kandev/kandev/internal/agent/handlers" agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" editorcontroller "github.com/kandev/kandev/internal/editors/controller" @@ -533,6 +534,7 @@ func main() { log, ) taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log) + agenthandlers.RegisterShellRoutes(router, lifecycleMgr, log) log.Debug("Registered Task Service handlers (HTTP + WebSocket)") workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log) diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index fea5684600..40a0122d97 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -4,10 +4,14 @@ package handlers import ( "context" "fmt" + "net/http" "time" + "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/scripts" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -16,15 +20,17 @@ import ( // Shell output is streamed via the lifecycle manager and event bus. // This handler provides shell.status, shell.subscribe (for buffer), and shell.input. type ShellHandlers struct { - lifecycleMgr *lifecycle.Manager - logger *logger.Logger + lifecycleMgr *lifecycle.Manager + scriptService scripts.ScriptService + logger *logger.Logger } // NewShellHandlers creates a new ShellHandlers instance -func NewShellHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *ShellHandlers { +func NewShellHandlers(lifecycleMgr *lifecycle.Manager, scriptService scripts.ScriptService, log *logger.Logger) *ShellHandlers { return &ShellHandlers{ - lifecycleMgr: lifecycleMgr, - logger: log.WithFields(zap.String("component", "shell_handlers")), + lifecycleMgr: lifecycleMgr, + scriptService: scriptService, + logger: log.WithFields(zap.String("component", "shell_handlers")), } } @@ -33,6 +39,9 @@ func (h *ShellHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionShellStatus, h.wsShellStatus) d.RegisterFunc(ws.ActionShellSubscribe, h.wsShellSubscribe) d.RegisterFunc(ws.ActionShellInput, h.wsShellInput) + d.RegisterFunc(ws.ActionUserShellList, h.wsUserShellList) + d.RegisterFunc(ws.ActionUserShellCreate, h.wsUserShellCreate) + d.RegisterFunc(ws.ActionUserShellStop, h.wsUserShellStop) } // ShellStatusRequest for shell.status action @@ -178,3 +187,205 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. "success": true, }) } + +// UserShellListRequest for user_shell.list action +type UserShellListRequest struct { + SessionID string `json:"session_id"` +} + +// wsUserShellList returns all running user shells for a session +func (h *ShellHandlers) wsUserShellList(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UserShellListRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + 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{}{ + "shells": []interface{}{}, + }) + } + + // Get list of user shells + shells := interactiveRunner.ListUserShells(req.SessionID) + + h.logger.Debug("listing user shells", + zap.String("session_id", req.SessionID), + zap.Int("count", len(shells))) + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "shells": shells, + }) +} + +// UserShellCreateRequest for user_shell.create action +type UserShellCreateRequest struct { + SessionID string `json:"session_id"` + ScriptID string `json:"script_id,omitempty"` // Optional: create a script terminal instead of plain shell +} + +// wsUserShellCreate creates a new user shell terminal and returns the assigned ID and label +func (h *ShellHandlers) wsUserShellCreate(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UserShellCreateRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + + // Get the interactive runner + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + return nil, fmt.Errorf("interactive runner not available") + } + + var terminalID, label, initialCommand string + var closable bool + + if req.ScriptID != "" { + // Creating a script terminal - look up the script + if h.scriptService == nil { + return nil, fmt.Errorf("script service not available") + } + + script, err := h.scriptService.GetRepositoryScript(ctx, req.ScriptID) + if err != nil { + h.logger.Error("failed to get repository script", + zap.String("script_id", req.ScriptID), + zap.Error(err)) + return nil, fmt.Errorf("invalid script ID: %w", err) + } + + terminalID = "script-" + uuid.New().String() + label = script.Name + initialCommand = script.Command + closable = true // Script terminals are always closable + + // Register the script terminal with the interactive runner + interactiveRunner.RegisterScriptShell(req.SessionID, terminalID, label, initialCommand) + + h.logger.Info("created script terminal", + zap.String("session_id", req.SessionID), + zap.String("terminal_id", terminalID), + zap.String("label", label), + zap.String("script_id", req.ScriptID), + zap.String("initial_command", initialCommand)) + } else { + // Creating a plain shell terminal + result := interactiveRunner.CreateUserShell(req.SessionID) + terminalID = result.TerminalID + label = result.Label + closable = result.Closable + + h.logger.Info("created user shell", + zap.String("session_id", req.SessionID), + zap.String("terminal_id", terminalID), + zap.String("label", label), + zap.Bool("closable", closable)) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "terminal_id": terminalID, + "label": label, + "closable": closable, + "initial_command": initialCommand, + }) +} + +// UserShellStopRequest for user_shell.stop action +type UserShellStopRequest struct { + SessionID string `json:"session_id"` + TerminalID string `json:"terminal_id"` +} + +// wsUserShellStop stops a user shell terminal process +func (h *ShellHandlers) wsUserShellStop(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req UserShellStopRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if req.TerminalID == "" { + return nil, fmt.Errorf("terminal_id is required") + } + + // Get the interactive runner + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + return nil, fmt.Errorf("interactive runner not available") + } + + // Stop the user shell + if err := interactiveRunner.StopUserShell(ctx, req.SessionID, req.TerminalID); err != nil { + h.logger.Warn("failed to stop user shell", + zap.String("session_id", req.SessionID), + zap.String("terminal_id", req.TerminalID), + zap.Error(err)) + // Don't return error - shell may already be stopped + } + + h.logger.Info("user shell stopped", + zap.String("session_id", req.SessionID), + zap.String("terminal_id", req.TerminalID)) + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "success": true, + }) +} + +// RegisterShellRoutes registers HTTP routes for shell operations. +// This is separate from RegisterHandlers which registers WebSocket handlers. +func RegisterShellRoutes(router *gin.Engine, lifecycleMgr *lifecycle.Manager, log *logger.Logger) { + handlers := NewShellHandlers(lifecycleMgr, nil, log) + api := router.Group("/api/v1") + api.GET("/sessions/:id/terminals", handlers.httpListTerminals) +} + +// httpListTerminals returns all terminals for a session (for SSR). +func (h *ShellHandlers) httpListTerminals(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + + // Get the interactive runner + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + c.JSON(http.StatusOK, gin.H{"terminals": []interface{}{}}) + return + } + + // Get list of user shells + shells := interactiveRunner.ListUserShells(sessionID) + + h.logger.Debug("listing terminals via HTTP", + zap.String("session_id", sessionID), + zap.Int("count", len(shells))) + + // Transform to response format + terminals := make([]map[string]interface{}, len(shells)) + for i, shell := range shells { + terminals[i] = map[string]interface{}{ + "terminal_id": shell.TerminalID, + "process_id": shell.ProcessID, + "running": shell.Running, + "label": shell.Label, + "closable": shell.Closable, + "initial_command": shell.InitialCommand, + } + } + + c.JSON(http.StatusOK, gin.H{"terminals": terminals}) +} diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index a468698296..6b12e57add 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -80,7 +80,7 @@ func TestNewShellHandlers(t *testing.T) { log := newTestLogger() // NewShellHandlers accepts *lifecycle.Manager, but we can pass nil for basic construction test - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) if handlers == nil { t.Fatal("expected non-nil handlers") @@ -95,7 +95,7 @@ func TestNewShellHandlers(t *testing.T) { func TestRegisterHandlers(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) dispatcher := ws.NewDispatcher() handlers.RegisterHandlers(dispatcher) @@ -113,7 +113,7 @@ func TestRegisterHandlers(t *testing.T) { func TestWsShellStatus_InvalidPayload(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) // Create a message with invalid payload msg := &ws.Message{ @@ -130,7 +130,7 @@ func TestWsShellStatus_InvalidPayload(t *testing.T) { func TestWsShellStatus_MissingTaskID(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) // Create a message with empty session_id msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: ""}) @@ -146,7 +146,7 @@ func TestWsShellStatus_MissingTaskID(t *testing.T) { func TestWsShellInput_InvalidPayload(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) msg := &ws.Message{ ID: "test-1", @@ -162,7 +162,7 @@ func TestWsShellInput_InvalidPayload(t *testing.T) { func TestWsShellInput_MissingTaskID(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) msg, _ := ws.NewRequest("test-1", ws.ActionShellInput, ShellInputRequest{SessionID: "", Data: "test"}) @@ -189,7 +189,7 @@ func newTestManager() *lifecycle.Manager { func TestWsShellStatus_NoInstanceFound(t *testing.T) { log := newTestLogger() mgr := newTestManager() - handlers := NewShellHandlers(mgr, log) + handlers := NewShellHandlers(mgr, nil, log) msg, _ := ws.NewRequest("test-1", ws.ActionShellStatus, ShellStatusRequest{SessionID: "session-1"}) @@ -215,7 +215,7 @@ func TestWsShellStatus_NoInstanceFound(t *testing.T) { func TestWsShellStatus_NoClientAvailable(t *testing.T) { log := newTestLogger() mgr := newTestManager() - handlers := NewShellHandlers(mgr, log) + handlers := NewShellHandlers(mgr, nil, log) // When no instance exists for the task, handler returns "no agent running" // Note: Testing the "agent client not available" path requires injecting an @@ -242,7 +242,7 @@ func TestWsShellStatus_NoClientAvailable(t *testing.T) { func TestWsShellInput_NoInstanceFound(t *testing.T) { log := newTestLogger() mgr := newTestManager() - handlers := NewShellHandlers(mgr, log) + handlers := NewShellHandlers(mgr, nil, log) msg, _ := ws.NewRequest("test-1", ws.ActionShellInput, ShellInputRequest{ SessionID: "session-1", @@ -263,7 +263,7 @@ func TestNewShellHandlers_WithManager(t *testing.T) { log := newTestLogger() mgr := newTestManager() - handlers := NewShellHandlers(mgr, log) + handlers := NewShellHandlers(mgr, nil, log) if handlers == nil { t.Fatal("expected non-nil handlers") @@ -275,7 +275,7 @@ func TestNewShellHandlers_WithManager(t *testing.T) { func TestRegisterHandlers_DispatcherReceivesMessages(t *testing.T) { log := newTestLogger() - handlers := NewShellHandlers(nil, log) + handlers := NewShellHandlers(nil, nil, log) dispatcher := ws.NewDispatcher() handlers.RegisterHandlers(dispatcher) @@ -289,3 +289,217 @@ func TestRegisterHandlers_DispatcherReceivesMessages(t *testing.T) { t.Error("expected error from dispatcher") } } + +// --- User Shell Handler Tests --- + +func TestRegisterHandlers_UserShellActions(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + dispatcher := ws.NewDispatcher() + handlers.RegisterHandlers(dispatcher) + + // Verify user shell actions are registered + if !dispatcher.HasHandler(ws.ActionUserShellList) { + t.Error("expected user_shell.list handler to be registered") + } + if !dispatcher.HasHandler(ws.ActionUserShellCreate) { + t.Error("expected user_shell.create handler to be registered") + } + if !dispatcher.HasHandler(ws.ActionUserShellStop) { + t.Error("expected user_shell.stop handler to be registered") + } +} + +func TestWsUserShellList_InvalidPayload(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionUserShellList, + Payload: json.RawMessage(`{invalid json`), + } + + _, err := handlers.wsUserShellList(context.Background(), msg) + if err == nil { + t.Error("expected error for invalid payload") + } +} + +func TestWsUserShellList_MissingSessionID(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellList, UserShellListRequest{SessionID: ""}) + + _, err := handlers.wsUserShellList(context.Background(), msg) + if err == nil { + t.Error("expected error for missing session_id") + } + if err.Error() != "session_id is required" { + t.Errorf("expected 'session_id is required', got: %v", err) + } +} + +func TestWsUserShellList_NoInteractiveRunner(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + handlers := NewShellHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellList, UserShellListRequest{SessionID: "session-1"}) + + resp, err := handlers.wsUserShellList(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should return empty shells since interactive runner is not set up + var payload map[string]interface{} + if err := resp.ParsePayload(&payload); err != nil { + t.Fatalf("failed to parse payload: %v", err) + } + + shells, ok := payload["shells"] + if !ok { + t.Error("expected 'shells' in response") + } + shellList, ok := shells.([]interface{}) + if !ok { + t.Errorf("expected shells to be an array, got %T", shells) + } + if len(shellList) != 0 { + t.Errorf("expected empty shells array, got %d items", len(shellList)) + } +} + +func TestWsUserShellCreate_InvalidPayload(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionUserShellCreate, + Payload: json.RawMessage(`{invalid json`), + } + + _, err := handlers.wsUserShellCreate(context.Background(), msg) + if err == nil { + t.Error("expected error for invalid payload") + } +} + +func TestWsUserShellCreate_MissingSessionID(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellCreate, UserShellCreateRequest{SessionID: ""}) + + _, err := handlers.wsUserShellCreate(context.Background(), msg) + if err == nil { + t.Error("expected error for missing session_id") + } + if err.Error() != "session_id is required" { + t.Errorf("expected 'session_id is required', got: %v", err) + } +} + +func TestWsUserShellCreate_NoInteractiveRunner(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + handlers := NewShellHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellCreate, UserShellCreateRequest{SessionID: "session-1"}) + + _, err := handlers.wsUserShellCreate(context.Background(), msg) + if err == nil { + t.Error("expected error when interactive runner not available") + } + if err.Error() != "interactive runner not available" { + t.Errorf("expected 'interactive runner not available', got: %v", err) + } +} + +func TestWsUserShellCreate_ScriptID_NoScriptService(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + // Pass nil for script service + handlers := NewShellHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellCreate, UserShellCreateRequest{ + SessionID: "session-1", + ScriptID: "script-123", + }) + + // This will fail because interactive runner is nil (checked before script service), + // so the error is "interactive runner not available" + _, err := handlers.wsUserShellCreate(context.Background(), msg) + if err == nil { + t.Error("expected error") + } +} + +func TestWsUserShellStop_InvalidPayload(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionUserShellStop, + Payload: json.RawMessage(`{invalid json`), + } + + _, err := handlers.wsUserShellStop(context.Background(), msg) + if err == nil { + t.Error("expected error for invalid payload") + } +} + +func TestWsUserShellStop_MissingSessionID(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellStop, UserShellStopRequest{SessionID: "", TerminalID: "term-1"}) + + _, err := handlers.wsUserShellStop(context.Background(), msg) + if err == nil { + t.Error("expected error for missing session_id") + } + if err.Error() != "session_id is required" { + t.Errorf("expected 'session_id is required', got: %v", err) + } +} + +func TestWsUserShellStop_MissingTerminalID(t *testing.T) { + log := newTestLogger() + handlers := NewShellHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellStop, UserShellStopRequest{SessionID: "session-1", TerminalID: ""}) + + _, err := handlers.wsUserShellStop(context.Background(), msg) + if err == nil { + t.Error("expected error for missing terminal_id") + } + if err.Error() != "terminal_id is required" { + t.Errorf("expected 'terminal_id is required', got: %v", err) + } +} + +func TestWsUserShellStop_NoInteractiveRunner(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + handlers := NewShellHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionUserShellStop, UserShellStopRequest{ + SessionID: "session-1", + TerminalID: "term-1", + }) + + _, err := handlers.wsUserShellStop(context.Background(), msg) + if err == nil { + t.Error("expected error when interactive runner not available") + } + if err.Error() != "interactive runner not available" { + t.Errorf("expected 'interactive runner not available', got: %v", err) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 52451d2bc7..43fad3ce16 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -2789,9 +2789,18 @@ func (m *Manager) handlePassthroughStatus(status *agentctltypes.ProcessStatusUpd m.eventPublisher.PublishProcessStatus(execution, clientStatus) // Check if process exited and should be auto-restarted + // Only restart if this is the ACTUAL passthrough process, not user shell terminals // Run asynchronously to allow the old process to be cleaned up first if status.Status == agentctltypes.ProcessStatusExited || status.Status == agentctltypes.ProcessStatusFailed { - go m.handlePassthroughExit(execution, status) + // Only trigger auto-restart for the passthrough process, not for user shell terminals + if execution.PassthroughProcessID != "" && status.ProcessID == execution.PassthroughProcessID { + go m.handlePassthroughExit(execution, status) + } else { + m.logger.Debug("process exited but not the passthrough process, skipping auto-restart", + zap.String("session_id", status.SessionID), + zap.String("exited_process_id", status.ProcessID), + zap.String("passthrough_process_id", execution.PassthroughProcessID)) + } } } diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index 52c51eb960..0f3460758e 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -14,6 +14,8 @@ import ( "os" "os/exec" "regexp" + "sort" + "strings" "sync" "syscall" "time" @@ -40,6 +42,7 @@ type InteractiveStartRequest struct { ImmediateStart bool `json:"immediate_start,omitempty"` // Start immediately with default dimensions (don't wait for resize) DefaultCols int `json:"default_cols,omitempty"` // Default columns if ImmediateStart (default 120) DefaultRows int `json:"default_rows,omitempty"` // Default rows if ImmediateStart (default 40) + InitialCommand string `json:"initial_command,omitempty"` // Command to write to stdin after shell starts (for script terminals) } // InteractiveProcessInfo represents the state of an interactive process. @@ -122,6 +125,15 @@ type sessionWebSocket struct { mu sync.RWMutex } +// userShellEntry tracks a user shell with its metadata. +type userShellEntry struct { + ProcessID string + Label string // Display name (e.g., "Terminal" or "Terminal 2") + InitialCommand string // Command that was run when shell started (empty for plain shells) + Closable bool // Whether the terminal can be closed (first terminal is not closable) + CreatedAt time.Time // When the shell was created (for stable ordering) +} + // InteractiveRunner manages interactive PTY-based processes with stdin support. type InteractiveRunner struct { logger *logger.Logger @@ -138,6 +150,10 @@ type InteractiveRunner struct { // Session-level WebSocket tracking - survives process restarts sessionWsMu sync.RWMutex sessionWs map[string]*sessionWebSocket + + // User shell processes - key: "sessionId:terminalId" + userShellsMu sync.RWMutex + userShells map[string]*userShellEntry } // NewInteractiveRunner creates a new interactive process runner. @@ -148,6 +164,7 @@ func NewInteractiveRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger bufferMaxBytes: bufferMaxBytes, processes: make(map[string]*interactiveProcess), sessionWs: make(map[string]*sessionWebSocket), + userShells: make(map[string]*userShellEntry), } } @@ -363,6 +380,29 @@ func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows in go r.readOutput(proc) go r.wait(proc) + // If an initial command was provided, write it to the PTY after a short delay + // to allow the shell to initialize and display its prompt + if req.InitialCommand != "" { + go func() { + time.Sleep(100 * time.Millisecond) + proc.mu.Lock() + pty := proc.ptmx + proc.mu.Unlock() + if pty != nil { + _, err := pty.Write([]byte(req.InitialCommand + "\n")) + if err != nil { + r.logger.Warn("failed to write initial command to PTY", + zap.String("process_id", proc.info.ID), + zap.Error(err)) + } else { + r.logger.Debug("wrote initial command to PTY", + zap.String("process_id", proc.info.ID), + zap.String("command", req.InitialCommand)) + } + } + }() + } + return nil } @@ -823,6 +863,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) { ) // Log buffer contents if process exited with error (helps debug startup failures) + // Use Debug level since non-zero exit is normal for killed processes (e.g., user closing terminal) if status == types.ProcessStatusFailed && proc.buffer != nil { chunks := proc.buffer.snapshot() if len(chunks) > 0 { @@ -834,7 +875,7 @@ func (r *InteractiveRunner) wait(proc *interactiveProcess) { if len(combinedOutput) > 2000 { combinedOutput = combinedOutput[:2000] + "...(truncated)" } - r.logger.Error("interactive process output before exit", + r.logger.Debug("interactive process output before exit", zap.String("process_id", proc.info.ID), zap.String("session_id", proc.info.SessionID), zap.Int("exit_code", exitCode), @@ -1189,6 +1230,373 @@ func (r *InteractiveRunner) WriteToDirectOutputBySession(sessionID string, data return err } +// UserShellOptions contains optional parameters for starting a user shell. +type UserShellOptions struct { + Label string // Display name (e.g., "Terminal" or script name) + InitialCommand string // Command to run after shell starts + Closable *bool // Whether the terminal can be closed (nil = auto-determine) +} + +// CreateUserShellResult contains the result of creating a new user shell. +type CreateUserShellResult struct { + TerminalID string `json:"terminal_id"` + Label string `json:"label"` + Closable bool `json:"closable"` +} + +// 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. +// 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 { + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + // Count existing plain shell terminals for this session + prefix := sessionID + ":" + 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 + } 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 + Label: label, + InitialCommand: "", + Closable: closable, + CreatedAt: time.Now().UTC(), + } + + return CreateUserShellResult{ + TerminalID: terminalID, + Label: label, + Closable: closable, + } +} + +// 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 + + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + r.userShells[key] = &userShellEntry{ + ProcessID: "", // No process yet - will be started when WebSocket connects + Label: label, + InitialCommand: initialCommand, + Closable: true, // Script terminals are always closable + CreatedAt: time.Now().UTC(), + } +} + +// 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 + + // Normalize options + if opts == nil { + opts = &UserShellOptions{} + } + if opts.Label == "" { + 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 + } + + // Determine shell to use + shell := preferredShell + if shell == "" { + shell = os.Getenv("SHELL") + } + if shell == "" { + shell = "/bin/sh" + } + + req := InteractiveStartRequest{ + SessionID: sessionID, + Command: []string{shell, "-l"}, // Login shell + WorkingDir: workingDir, + InitialCommand: initialCommand, + } + + info, err := r.Start(ctx, req) + if err != nil { + 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 + if opts.Closable != nil { + closable = *opts.Closable + } + r.userShells[key] = &userShellEntry{ + ProcessID: info.ID, + Label: opts.Label, + InitialCommand: opts.InitialCommand, + Closable: closable, + CreatedAt: time.Now().UTC(), + } + } + r.userShellsMu.Unlock() + + r.logger.Info("started user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", info.ID), + zap.String("shell", shell), + zap.String("working_dir", workingDir), + zap.String("label", opts.Label), + zap.String("initial_command", opts.InitialCommand)) + + return info, nil +} + +// UserShellInfo contains information about a running user shell. +type UserShellInfo struct { + TerminalID string `json:"terminal_id"` + ProcessID string `json:"process_id"` + Running bool `json:"running"` + Label string `json:"label"` // Display name (e.g., "Terminal" or "Terminal 2") + Closable bool `json:"closable"` // Whether the terminal can be closed + InitialCommand string `json:"initial_command"` // Command that was run (empty for plain shells) + CreatedAt time.Time `json:"created_at"` // When the shell was created (for stable ordering) +} + +// ListUserShells returns all user shells for a session, sorted by creation time. +// If no plain shell terminals exist, automatically creates the first "Terminal" entry. +func (r *InteractiveRunner) ListUserShells(sessionID string) []UserShellInfo { + r.userShellsMu.Lock() + defer r.userShellsMu.Unlock() + + prefix := sessionID + ":" + var shells []UserShellInfo + hasPlainShell := false + + for key, entry := range r.userShells { + if strings.HasPrefix(key, prefix) { + terminalID := key[len(prefix):] + // Check if process is still alive + _, running := r.Get(entry.ProcessID, false) + shells = append(shells, UserShellInfo{ + TerminalID: terminalID, + ProcessID: entry.ProcessID, + Running: running, + Label: entry.Label, + Closable: entry.Closable, + InitialCommand: entry.InitialCommand, + CreatedAt: entry.CreatedAt, + }) + // Check if this is a plain shell (not a script terminal) + if entry.InitialCommand == "" { + hasPlainShell = true + } + } + } + + // Auto-create the first "Terminal" if no plain shells exist + if !hasPlainShell { + terminalID := "shell-" + uuid.New().String() + now := time.Now().UTC() + entry := &userShellEntry{ + ProcessID: "", // No process yet - will be started when WebSocket connects + Label: "Terminal", + InitialCommand: "", + Closable: false, // First terminal is not closable + CreatedAt: now, + } + r.userShells[prefix+terminalID] = entry + + shells = append(shells, UserShellInfo{ + TerminalID: terminalID, + ProcessID: "", + Running: false, + Label: "Terminal", + Closable: false, + InitialCommand: "", + CreatedAt: now, + }) + } + + // Sort by creation time for stable ordering + sort.Slice(shells, func(i, j int) bool { + return shells[i].CreatedAt.Before(shells[j].CreatedAt) + }) + + return shells +} + +// StopUserShell stops a user shell for a terminal tab. +func (r *InteractiveRunner) StopUserShell(ctx context.Context, sessionID, terminalID string) error { + key := sessionID + ":" + terminalID + + r.userShellsMu.Lock() + entry, exists := r.userShells[key] + if exists { + delete(r.userShells, key) + } + r.userShellsMu.Unlock() + + if !exists { + return nil + } + + r.logger.Info("stopping user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", entry.ProcessID)) + + return r.Stop(ctx, entry.ProcessID) +} + +// ResizeUserShell resizes the PTY for a user shell. +func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, rows uint16) error { + key := sessionID + ":" + 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) + } + + proc, ok := r.get(entry.ProcessID) + if !ok { + return fmt.Errorf("process not found: %s", entry.ProcessID) + } + + // Lazy start: spawn process on first resize when we have exact dimensions + var startErr error + proc.startOnce.Do(func() { + r.logger.Info("first resize received for user shell - starting process", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Uint16("cols", cols), + zap.Uint16("rows", rows)) + startErr = r.startProcess(proc, int(cols), int(rows)) + }) + if startErr != nil { + return fmt.Errorf("failed to start process on first resize: %w", startErr) + } + + proc.mu.Lock() + ptyInstance := proc.ptmx + proc.mu.Unlock() + + if ptyInstance != nil { + if err := pty.Setsize(ptyInstance, &pty.Winsize{ + Cols: cols, + Rows: rows, + }); err != nil { + return fmt.Errorf("failed to resize PTY: %w", err) + } + } + + return nil +} + +// GetUserShellPtyWriter returns the PTY writer for a user shell. +func (r *InteractiveRunner) GetUserShellPtyWriter(sessionID, terminalID string) (io.Writer, string, error) { + key := sessionID + ":" + 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) + } + + writer, err := r.GetPtyWriter(entry.ProcessID) + if err != nil { + return nil, entry.ProcessID, err + } + + return writer, entry.ProcessID, nil +} + +// ClearUserShellDirectOutput clears the direct output for a user shell. +func (r *InteractiveRunner) ClearUserShellDirectOutput(sessionID, terminalID string) { + key := sessionID + ":" + terminalID + + r.userShellsMu.RLock() + entry, exists := r.userShells[key] + r.userShellsMu.RUnlock() + + if !exists { + return + } + + proc, ok := r.get(entry.ProcessID) + if !ok { + return + } + + proc.directOutputMu.Lock() + proc.directOutput = nil + proc.hasActiveWebSocket = false + proc.directOutputMu.Unlock() + + r.logger.Info("direct output cleared for user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID)) +} + // containsDSRQuery checks if data contains a Device Status Report (cursor position) query. // DSR query is: ESC [ 6 n or ESC [ ? 6 n func containsDSRQuery(data []byte) bool { diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner_test.go b/apps/backend/internal/agentctl/server/process/interactive_runner_test.go index c05443bf05..7a681dab4b 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner_test.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner_test.go @@ -687,3 +687,304 @@ func bytesContains(data, substr []byte) bool { } return false } + +// --- User Shell Tests --- + +func TestInteractiveRunner_CreateUserShell_First(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + result := runner.CreateUserShell("session-1") + + if result.TerminalID == "" { + t.Error("CreateUserShell() returned empty TerminalID") + } + if !strings.HasPrefix(result.TerminalID, "shell-") { + t.Errorf("CreateUserShell() TerminalID = %q, want prefix 'shell-'", result.TerminalID) + } + if result.Label != "Terminal" { + t.Errorf("CreateUserShell() Label = %q, want 'Terminal'", result.Label) + } + if result.Closable { + t.Error("CreateUserShell() first terminal should not be closable") + } +} + +func TestInteractiveRunner_CreateUserShell_Subsequent(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + first := runner.CreateUserShell("session-1") + second := runner.CreateUserShell("session-1") + + if first.TerminalID == second.TerminalID { + t.Error("CreateUserShell() should return different terminal IDs") + } + if second.Label != "Terminal 2" { + t.Errorf("CreateUserShell() second Label = %q, want 'Terminal 2'", second.Label) + } + if !second.Closable { + t.Error("CreateUserShell() second terminal should be closable") + } + + third := runner.CreateUserShell("session-1") + if third.Label != "Terminal 3" { + t.Errorf("CreateUserShell() third Label = %q, want 'Terminal 3'", third.Label) + } + if !third.Closable { + t.Error("CreateUserShell() third terminal should be closable") + } +} + +func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + r1 := runner.CreateUserShell("session-1") + r2 := runner.CreateUserShell("session-2") + + // Both should be "Terminal" (first in each session) + if r1.Label != "Terminal" { + t.Errorf("session-1 Label = %q, want 'Terminal'", r1.Label) + } + if r2.Label != "Terminal" { + 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") + } +} + +func TestInteractiveRunner_RegisterScriptShell(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + runner.RegisterScriptShell("session-1", "script-abc", "npm start", "npm run start") + + // Should appear in list + shells := runner.ListUserShells("session-1") + + // Should have 2: auto-created "Terminal" + registered script + if len(shells) != 2 { + t.Fatalf("ListUserShells() returned %d shells, want 2", len(shells)) + } + + // Find the script shell + var scriptShell *UserShellInfo + for i := range shells { + if shells[i].TerminalID == "script-abc" { + scriptShell = &shells[i] + break + } + } + if scriptShell == nil { + t.Fatal("script shell not found in list") + } + if scriptShell.Label != "npm start" { + t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label) + } + if scriptShell.InitialCommand != "npm run start" { + t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand) + } + if !scriptShell.Closable { + t.Error("script shell should be closable") + } + if scriptShell.ProcessID != "" { + t.Errorf("script shell should have empty ProcessID before WebSocket connect, got %q", scriptShell.ProcessID) + } +} + +func TestInteractiveRunner_RegisterScriptShell_DoesNotAffectShellCount(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Register a script terminal + runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") + + // Create a plain shell - should be "Terminal" (first plain shell), not "Terminal 2" + result := runner.CreateUserShell("session-1") + if result.Label != "Terminal" { + t.Errorf("CreateUserShell() Label = %q, want 'Terminal' (scripts should not count)", result.Label) + } + if result.Closable { + t.Error("first plain shell should not be closable regardless of script terminals") + } +} + +func TestInteractiveRunner_ListUserShells_AutoCreatesFirst(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // First call should auto-create "Terminal" + shells := runner.ListUserShells("session-1") + + if len(shells) != 1 { + t.Fatalf("ListUserShells() returned %d shells, want 1", len(shells)) + } + if shells[0].Label != "Terminal" { + t.Errorf("auto-created shell Label = %q, want 'Terminal'", shells[0].Label) + } + if shells[0].Closable { + t.Error("auto-created first shell should not be closable") + } + if shells[0].Running { + t.Error("auto-created shell should not be running (no process)") + } + if !strings.HasPrefix(shells[0].TerminalID, "shell-") { + t.Errorf("auto-created shell TerminalID = %q, want prefix 'shell-'", shells[0].TerminalID) + } +} + +func TestInteractiveRunner_ListUserShells_StableAfterAutoCreate(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // First call auto-creates + shells1 := runner.ListUserShells("session-1") + // Second call should return the same list (not create another) + shells2 := runner.ListUserShells("session-1") + + if len(shells1) != 1 || len(shells2) != 1 { + t.Fatalf("ListUserShells() should return 1 shell each time, got %d and %d", len(shells1), len(shells2)) + } + if shells1[0].TerminalID != shells2[0].TerminalID { + t.Error("ListUserShells() should return the same terminal ID across calls") + } +} + +func TestInteractiveRunner_ListUserShells_Sorted(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create shells with a small time gap + runner.CreateUserShell("session-1") + time.Sleep(10 * time.Millisecond) + runner.CreateUserShell("session-1") + time.Sleep(10 * time.Millisecond) + runner.RegisterScriptShell("session-1", "script-1", "Build", "make build") + + shells := runner.ListUserShells("session-1") + if len(shells) != 3 { + t.Fatalf("ListUserShells() returned %d shells, want 3", len(shells)) + } + + // Should be sorted by creation time + for i := 1; i < len(shells); i++ { + if shells[i].CreatedAt.Before(shells[i-1].CreatedAt) { + t.Errorf("shells not sorted by creation time: shell[%d] (%v) before shell[%d] (%v)", + i, shells[i].CreatedAt, i-1, shells[i-1].CreatedAt) + } + } +} + +func TestInteractiveRunner_ListUserShells_IsolatedBySessions(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + runner.CreateUserShell("session-1") + runner.CreateUserShell("session-1") + runner.CreateUserShell("session-2") + + shells1 := runner.ListUserShells("session-1") + shells2 := runner.ListUserShells("session-2") + + if len(shells1) != 2 { + t.Errorf("session-1 should have 2 shells, got %d", len(shells1)) + } + if len(shells2) != 1 { + t.Errorf("session-2 should have 1 shell, got %d", len(shells2)) + } +} + +func TestInteractiveRunner_StopUserShell(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create a shell + result := runner.CreateUserShell("session-1") + + // Verify it's in the list + shells := runner.ListUserShells("session-1") + found := false + for _, s := range shells { + if s.TerminalID == result.TerminalID { + found = true + break + } + } + if !found { + t.Fatal("created shell not found in list") + } + + // Stop the shell (no process running, so this should remove the entry) + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", result.TerminalID) + // Error is expected since there's no process to stop + _ = err + + // Shell should be removed from tracking + shells = runner.ListUserShells("session-1") + for _, s := range shells { + if s.TerminalID == result.TerminalID { + t.Error("stopped shell should be removed from list") + } + } +} + +func TestInteractiveRunner_StopUserShell_NonExistent(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Stopping a non-existent shell should not error + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", "nonexistent") + if err != nil { + t.Errorf("StopUserShell() for non-existent shell should return nil, got %v", err) + } +} + +func TestInteractiveRunner_StopUserShell_ScriptTerminal(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Register and then stop a script terminal + runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build") + + ctx := context.Background() + err := runner.StopUserShell(ctx, "session-1", "script-abc") + _ = err // Error expected since no process + + // Script should be removed from list (auto-created "Terminal" may still appear) + shells := runner.ListUserShells("session-1") + for _, s := range shells { + if s.TerminalID == "script-abc" { + t.Error("stopped script terminal should be removed from list") + } + } +} + +func TestInteractiveRunner_CreateUserShell_AtomicRegistration(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + // Create a shell - it should be immediately visible in ListUserShells + result := runner.CreateUserShell("session-1") + + shells := runner.ListUserShells("session-1") + found := false + for _, s := range shells { + if s.TerminalID == result.TerminalID { + found = true + if s.Label != result.Label { + t.Errorf("shell Label mismatch: list=%q, create=%q", s.Label, result.Label) + } + if s.Closable != result.Closable { + t.Errorf("shell Closable mismatch: list=%v, create=%v", s.Closable, result.Closable) + } + break + } + } + if !found { + t.Error("CreateUserShell() result should be immediately visible in ListUserShells()") + } +} diff --git a/apps/backend/internal/common/scripts/scripts.go b/apps/backend/internal/common/scripts/scripts.go new file mode 100644 index 0000000000..dbe3830211 --- /dev/null +++ b/apps/backend/internal/common/scripts/scripts.go @@ -0,0 +1,16 @@ +// Package scripts provides shared types for repository script lookups. +package scripts + +import "context" + +// RepositoryScript holds the minimal fields needed when looking up a script by ID. +type RepositoryScript struct { + ID string + Name string + Command string +} + +// ScriptService provides access to repository scripts. +type ScriptService interface { + GetRepositoryScript(ctx context.Context, id string) (*RepositoryScript, error) +} diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 8376073a31..800afcdfae 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -5,6 +5,7 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/scripts" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -36,8 +37,8 @@ func NewGateway(log *logger.Logger) *Gateway { // SetLifecycleManager enables the dedicated terminal WebSocket handler for passthrough mode. // This must be called before SetupRoutes if terminal passthrough is needed. -func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager) { - g.TerminalHandler = NewTerminalHandler(lifecycleMgr, g.logger) +func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userService UserService, scriptService scripts.ScriptService) { + g.TerminalHandler = NewTerminalHandler(lifecycleMgr, userService, scriptService, g.logger) } // SetupRoutes adds the WebSocket routes to the Gin engine diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index b5a67ecda8..4161e3760c 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -3,6 +3,7 @@ package websocket import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -18,20 +19,30 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/scripts" ) +// UserService interface for getting user preferences. +type UserService interface { + PreferredShell(ctx context.Context) (string, error) +} + // TerminalHandler handles dedicated binary WebSocket connections for terminal I/O. // This bypasses JSON encoding for raw PTY communication via xterm.js AttachAddon. type TerminalHandler struct { - lifecycleMgr *lifecycle.Manager - logger *logger.Logger + lifecycleMgr *lifecycle.Manager + userService UserService + scriptService scripts.ScriptService + logger *logger.Logger } // NewTerminalHandler creates a new TerminalHandler instance. -func NewTerminalHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *TerminalHandler { +func NewTerminalHandler(lifecycleMgr *lifecycle.Manager, userService UserService, scriptService scripts.ScriptService, log *logger.Logger) *TerminalHandler { return &TerminalHandler{ - lifecycleMgr: lifecycleMgr, - logger: log.WithFields(zap.String("component", "terminal_handler")), + lifecycleMgr: lifecycleMgr, + userService: userService, + scriptService: scriptService, + logger: log.WithFields(zap.String("component", "terminal_handler")), } } @@ -130,6 +141,9 @@ func (w *wsWriter) Close() error { // HandleTerminalWS handles WebSocket connections at /xterm.js/:sessionId // This creates a binary WebSocket bridge between xterm.js and the PTY. +// Query parameter terminalId determines the type of terminal: +// - "default" or empty: Agent passthrough terminal (CLI passthrough mode) +// - "shell-1", "shell-2", etc.: Independent user shell terminals func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { sessionID := c.Param("sessionId") if sessionID == "" { @@ -137,8 +151,23 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { return } + terminalID := c.Query("terminalId") + // terminalId is required - "default" is for agent passthrough, anything else is for user shells + // Empty terminalId is rejected to prevent accidental fallback behavior + if terminalID == "" { + h.logger.Warn("terminal WebSocket connection rejected: terminalId query parameter is required (use 'default' for agent passthrough or a unique ID for user shells)", + zap.String("session_id", sessionID), + zap.String("raw_query", c.Request.URL.RawQuery), + zap.String("remote_addr", c.Request.RemoteAddr)) + c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId query parameter is required"}) + return + } + h.logger.Info("terminal WebSocket connection request", zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Bool("is_agent_passthrough", terminalID == "default"), + zap.String("raw_query", c.Request.URL.RawQuery), zap.String("remote_addr", c.Request.RemoteAddr)) // Get the interactive runner first @@ -149,6 +178,14 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { return } + // Route based on terminal ID + if terminalID != "default" { + // User shell terminal - create independent shell process + h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner) + return + } + + // Agent passthrough terminal - existing logic // Ensure passthrough execution exists and is running. // This handles: // 1. Normal case: execution exists with running process @@ -499,3 +536,327 @@ func (h *TerminalHandler) handleResize( return resize.Cols, resize.Rows } + +// handleUserShellWS handles WebSocket connections for user shell terminals. +// Each terminal tab gets its own independent shell process. +func (h *TerminalHandler) handleUserShellWS( + c *gin.Context, + sessionID string, + terminalID string, + interactiveRunner *process.InteractiveRunner, +) { + // Get optional parameters from query + scriptID := c.Query("scriptId") // Repository script ID to run + labelParam := c.Query("label") // Label for plain shell terminals + + var label string + var initialCommand string + + // If scriptId is provided, look up the script from the database + if scriptID != "" && h.scriptService != nil { + script, err := h.scriptService.GetRepositoryScript(c.Request.Context(), scriptID) + if err != nil { + h.logger.Error("failed to get repository script", + zap.String("script_id", scriptID), + zap.Error(err)) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid script ID"}) + return + } + label = script.Name + initialCommand = script.Command + h.logger.Info("handleUserShellWS: resolved script", + zap.String("script_id", scriptID), + zap.String("script_name", script.Name), + zap.String("script_command", script.Command)) + } else if labelParam != "" { + // Use label from query param for plain shell terminals + label = labelParam + } + + h.logger.Info("handleUserShellWS: starting user shell handling", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("script_id", scriptID), + zap.String("label", label), + zap.String("initial_command", initialCommand)) + + // Get preferred shell from user settings + var preferredShell string + if h.userService != nil { + shell, err := h.userService.PreferredShell(c.Request.Context()) + if err != nil { + h.logger.Debug("failed to get preferred shell, using default", + zap.Error(err)) + } else { + preferredShell = shell + } + } + + // Get working directory from execution (workspace path) + workingDir := "" + if execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID); exists { + workingDir = execution.WorkspacePath + h.logger.Info("handleUserShellWS: got working directory from execution", + zap.String("working_dir", workingDir)) + } else { + h.logger.Info("handleUserShellWS: no execution found, using empty working directory") + } + + // Build shell options + opts := &process.UserShellOptions{ + Label: label, + InitialCommand: initialCommand, + } + + // Start or get existing user shell for this terminal + h.logger.Info("handleUserShellWS: calling StartUserShell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("working_dir", workingDir), + zap.String("preferred_shell", preferredShell), + zap.String("label", opts.Label), + zap.String("initial_command", opts.InitialCommand)) + + info, err := interactiveRunner.StartUserShell( + c.Request.Context(), + 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)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + + processID := info.ID + h.logger.Info("handleUserShellWS: user shell started successfully", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", processID)) + + // Upgrade to WebSocket + conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Error("failed to upgrade to WebSocket", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + return + } + + h.logger.Info("user shell WebSocket connected", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", processID)) + + // Create WebSocket writer for output + wsw := newWsWriter(conn) + + // Run the user shell terminal bridge + h.runUserShellBridge(conn, sessionID, terminalID, processID, interactiveRunner, wsw) +} + +// runUserShellBridge handles WebSocket I/O for user shell terminals. +func (h *TerminalHandler) runUserShellBridge( + conn *gorillaws.Conn, + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, +) { + var ptyWriter io.Writer + var directOutputSet bool + + defer func() { + // 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) + + _ = wsw.Close() + _ = conn.Close() + + h.logger.Info("user shell WebSocket disconnected (process still running for potential reconnect)", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID)) + }() + + // Read from WebSocket and handle input/resize + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) { + h.logger.Debug("WebSocket read error", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + return + } + + if messageType != gorillaws.BinaryMessage && messageType != gorillaws.TextMessage { + continue + } + + if len(data) == 0 { + continue + } + + // Check for resize command (first byte 0x01) + if data[0] == resizeCommandByte { + // Set up PTY access BEFORE handling resize if not already done + if !directOutputSet { + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { + directOutputSet = true + } + } + + // Handle resize for user shell + h.handleUserShellResize(data[1:], sessionID, terminalID, interactiveRunner) + continue + } + + // Regular input - write to PTY if available + if ptyWriter != nil { + if _, err := ptyWriter.Write(data); err != nil { + h.logger.Debug("PTY write error", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + // Try to re-establish PTY access + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { + directOutputSet = true + // Retry write + if _, err := ptyWriter.Write(data); err != nil { + h.logger.Debug("PTY write error after reconnect", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + } + } + } else { + // PTY not ready yet - try to set it up + if h.setupUserShellPtyAccess(sessionID, terminalID, processID, interactiveRunner, wsw, &ptyWriter) { + directOutputSet = true + // Retry writing after setup + if _, err := ptyWriter.Write(data); err != nil { + h.logger.Debug("PTY write error after setup", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + } else { + h.logger.Debug("PTY not ready, dropping input", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Int("bytes", len(data))) + } + } + } +} + +// setupUserShellPtyAccess sets up PTY access for a user shell terminal. +func (h *TerminalHandler) setupUserShellPtyAccess( + sessionID string, + terminalID string, + processID string, + interactiveRunner *process.InteractiveRunner, + wsw *wsWriter, + ptyWriter *io.Writer, +) bool { + // Get PTY writer for the user shell + writer, _, err := interactiveRunner.GetUserShellPtyWriter(sessionID, terminalID) + if err != nil { + h.logger.Debug("user shell PTY writer not ready", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID)) + return false + } + + // Send buffered output + if chunks, ok := interactiveRunner.GetBuffer(processID); ok && len(chunks) > 0 { + var combined bytes.Buffer + for _, chunk := range chunks { + combined.WriteString(chunk.Data) + } + if combined.Len() > 0 { + if _, writeErr := wsw.Write(combined.Bytes()); writeErr != nil { + h.logger.Warn("failed to send buffered output", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(writeErr)) + } + } + } + + // Set up direct output + if err := interactiveRunner.SetDirectOutput(processID, wsw); err != nil { + h.logger.Error("failed to set direct output for user shell", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + return false + } + + *ptyWriter = writer + + h.logger.Info("user shell PTY access established", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.String("process_id", processID)) + + return true +} + +// handleUserShellResize processes resize commands for user shell terminals. +func (h *TerminalHandler) handleUserShellResize( + data []byte, + sessionID string, + terminalID string, + interactiveRunner *process.InteractiveRunner, +) { + var resize ResizePayload + if err := json.Unmarshal(data, &resize); err != nil { + h.logger.Warn("failed to parse resize command", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + return + } + + if resize.Cols == 0 || resize.Rows == 0 { + h.logger.Warn("invalid resize dimensions", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows)) + return + } + + // Resize the user shell PTY + if err := interactiveRunner.ResizeUserShell(sessionID, 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), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows), + zap.Error(err)) + } else { + h.logger.Debug("user shell PTY resized", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Uint16("cols", resize.Cols), + zap.Uint16("rows", resize.Rows)) + } + + // Give the process a moment to start if this was the first resize + time.Sleep(50 * time.Millisecond) +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 6ca31cbfb1..617f8b5251 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -180,6 +180,11 @@ const ( ActionShellInput = "shell.input" // Send input to shell ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit") + // User shell actions (independent terminal tabs) + ActionUserShellList = "user_shell.list" // List running user shells for a session + ActionUserShellCreate = "user_shell.create" // Create a new user shell terminal (assigns ID and label) + ActionUserShellStop = "user_shell.stop" // Stop a user shell terminal + // Session git actions (requests) ActionSessionGitSnapshots = "session.git.snapshots" // Get git snapshots for a session ActionSessionGitCommits = "session.git.commits" // Get commits for a session diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index e9fef3daf2..57e1ddd757 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -14,7 +14,9 @@ import { listWorkspaces, } from '@/lib/api'; import { listSessionTurns } from '@/lib/api/domains/session-api'; +import { fetchTerminals } from '@/lib/api/domains/user-shell-api'; import type { ListMessagesResponse, Task } from '@/lib/types/http'; +import type { Terminal } from '@/hooks/domains/session/use-terminals'; import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; import { TaskPageContent } from '@/components/task/task-page-content'; @@ -26,6 +28,7 @@ export default async function SessionPage({ let initialState: ReturnType | null = null; let task: Task | null = null; let sessionId: string | null = null; + let initialTerminals: Terminal[] = []; const defaultLayouts = await readLayoutDefaults(); try { @@ -39,7 +42,7 @@ export default async function SessionPage({ } task = await fetchTask(session.task_id, { cache: 'no-store' }); - const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse, userSettingsResponse] = await Promise.all([ + const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse, userSettingsResponse, terminalsResponse] = await Promise.all([ fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), listAgents({ cache: 'no-store' }), listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }), @@ -49,6 +52,7 @@ export default async function SessionPage({ listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })), listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })), fetchUserSettings({ cache: 'no-store' }).catch(() => null), + fetchTerminals(sessionId).catch(() => []), ]); const repositories = repositoriesResponse.repositories ?? []; const allSessions = allSessionsResponse.sessions ?? [session]; @@ -58,6 +62,14 @@ export default async function SessionPage({ const turns = turnsResponse.turns ?? []; const userSettings = userSettingsResponse?.settings; + // Transform terminals to frontend format + initialTerminals = terminalsResponse.map((t) => ({ + id: t.terminal_id, + type: t.initial_command ? 'script' as const : 'shell' as const, + label: t.label, + closable: t.closable, + })); + // Get repository scripts from the repository (already fetched with includeScripts) const repositoryId = task.repositories?.[0]?.repository_id; const repository = repositories.find(r => r.id === repositoryId); @@ -236,6 +248,7 @@ export default async function SessionPage({ sessionId={sessionId} initialRepositories={initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ''] ?? []} initialScripts={initialState?.repositoryScripts?.itemsByRepositoryId?.[task?.repositories?.[0]?.repository_id ?? ''] ?? []} + initialTerminals={initialTerminals} defaultLayouts={defaultLayouts} /> diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index e05d47a670..e775aaec79 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -33,6 +33,7 @@ import { import type { LocalRepository, Repository, RepositoryScript, Workspace } from '@/lib/types/http'; import { useRequest } from '@/lib/http/use-request'; import { useToast } from '@/components/toast-provider'; +import { useAppStore } from '@/components/state-provider'; type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] }; type RepositoryItem = RepositoryWithScripts & { __autoOpen?: boolean }; @@ -48,6 +49,7 @@ export function WorkspaceRepositoriesClient({ }: WorkspaceRepositoriesClientProps) { const router = useRouter(); const { toast } = useToast(); + const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts); const [repositoryItems, setRepositoryItems] = useState(repositories); const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories); const [discoveredRepositories, setDiscoveredRepositories] = useState([]); @@ -346,6 +348,9 @@ export function WorkspaceRepositoriesClient({ ? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item)) : [...prev, cloneRepository(nextRepo)] ); + + // Clear the scripts cache so task pages will refetch the updated scripts + clearRepositoryScripts(repoId); }; const handleDeleteRepository = async (repoId: string) => { diff --git a/apps/web/components/session-tabs.tsx b/apps/web/components/session-tabs.tsx index f43331209f..40657a6962 100644 --- a/apps/web/components/session-tabs.tsx +++ b/apps/web/components/session-tabs.tsx @@ -48,7 +48,7 @@ export function SessionTabs({ rightContent, }: SessionTabsProps) { const tabsList = ( - + {tabs.map((tab, index) => ( {separatorAfterIndex !== undefined && index === separatorAfterIndex + 1 && ( @@ -56,19 +56,19 @@ export function SessionTabs({ )} {tab.icon} - {tab.label} + {tab.label} {tab.closable && tab.onClose && ( ))} {showAddButton && onAddTab && ( - + + )} ); @@ -132,17 +136,17 @@ export function SessionTabs({ return ( {collapsible ? ( -
+
{tabsList} -
+
{rightContent} {collapseButton}
) : rightContent ? ( -
+
{tabsList} - {rightContent} +
{rightContent}
) : ( tabsList diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index fb49da4551..fc38297f70 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -101,7 +101,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({ {activeTaskId ? ( isPassthroughMode ? (
- +
) : ( - + diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index 1557d9dabd..7360f0d2ed 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -15,6 +15,8 @@ import { terminalTheme, applyTransparentBackground } from '@/lib/terminal-theme' type PassthroughTerminalProps = { sessionId?: string | null; + terminalId?: string; // Terminal tab identifier (e.g., "shell-1", "shell-2") + label?: string; // Label for plain shell terminals (sent to backend for persistence) }; // Debug flag - set to true to see detailed logs @@ -37,7 +39,10 @@ const MIN_HEIGHT = 100; * - Unicode11Addon enables proper unicode character support * - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}] */ -export function PassthroughTerminal({ sessionId: propSessionId }: PassthroughTerminalProps) { +export function PassthroughTerminal({ sessionId: propSessionId, terminalId, label }: PassthroughTerminalProps) { + // Debug: log props on every render + log('Render - props:', { propSessionId, terminalId, label }); + const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); @@ -312,9 +317,10 @@ export function PassthroughTerminal({ sessionId: propSessionId }: PassthroughTer // Connect WebSocket when ready useEffect(() => { - log('WebSocket effect:', { taskId, sessionId, canConnect, hasTerminal: !!xtermRef.current }); + log('WebSocket effect:', { taskId, sessionId, terminalId, canConnect, hasTerminal: !!xtermRef.current }); if (!taskId || !sessionId || !canConnect) { + log('WebSocket effect: early return - missing requirements', { taskId, sessionId, canConnect }); return; } if (!xtermRef.current || !fitAddonRef.current) { @@ -341,8 +347,18 @@ export function PassthroughTerminal({ sessionId: propSessionId }: PassthroughTer wsRef.current = null; } - const wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}`; - log('Connecting to', wsUrl); + // Build WebSocket URL - terminalId is required for user shells + // If terminalId is not provided, this is an error for user shell terminals + if (!terminalId) { + log('ERROR: terminalId is required but was not provided!', { sessionId, terminalId }); + return; + } + let wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}?terminalId=${encodeURIComponent(terminalId)}`; + if (label) { + // For plain shell terminals, send the label for persistence + wsUrl += `&label=${encodeURIComponent(label)}`; + } + log('Connecting to', wsUrl, { terminalId, label }); const ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; @@ -415,7 +431,7 @@ export function PassthroughTerminal({ sessionId: propSessionId }: PassthroughTer wsRef.current = null; } }; - }, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl]); + }, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, terminalId, label]); return (
- +
) : ( ; taskTitle?: string; baseBranch?: string; @@ -48,6 +50,7 @@ export const TaskLayout = memo(function TaskLayout({ sessionId = null, repository = null, initialScripts = [], + initialTerminals, defaultLayouts = {}, taskTitle, baseBranch, @@ -204,7 +207,7 @@ export const TaskLayout = memo(function TaskLayout({ {layoutState.right ? ( <> - + ) : null} diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index e375ee4e52..b711b282e8 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -5,6 +5,7 @@ import { TaskTopBar } from '@/components/task/task-top-bar'; import { TaskLayout } from '@/components/task/task-layout'; import { DebugOverlay } from '@/components/debug-overlay'; import type { Repository, RepositoryScript, Task } from '@/lib/types/http'; +import type { Terminal } from '@/hooks/domains/session/use-terminals'; import type { KanbanState } from '@/lib/state/slices'; import { DEBUG_UI } from '@/lib/config'; import { TooltipProvider } from '@kandev/ui/tooltip'; @@ -23,6 +24,7 @@ type TaskPageContentProps = { sessionId?: string | null; initialRepositories?: Repository[]; initialScripts?: RepositoryScript[]; + initialTerminals?: Terminal[]; defaultLayouts?: Record; }; @@ -37,6 +39,7 @@ export function TaskPageContent({ sessionId = null, initialRepositories = [], initialScripts = [], + initialTerminals, defaultLayouts = {}, }: TaskPageContentProps) { const [taskDetails, setTaskDetails] = useState(initialTask); @@ -260,6 +263,7 @@ export function TaskPageContent({ sessionId={effectiveSessionId ?? null} repository={repository ?? null} initialScripts={initialScripts} + initialTerminals={initialTerminals} defaultLayouts={defaultLayouts} taskTitle={task?.title} baseBranch={task?.repositories?.[0]?.base_branch} diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx index 73a4c387fd..8a0b7788d2 100644 --- a/apps/web/components/task/task-right-panel.tsx +++ b/apps/web/components/task/task-right-panel.tsx @@ -1,41 +1,34 @@ 'use client'; import type { ReactNode, MouseEvent } from 'react'; -import { memo, useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import { memo, useEffect, useCallback, useState, useMemo } from 'react'; import { Badge } from '@kandev/ui/badge'; import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session'; import { Group, Panel } from 'react-resizable-panels'; import { TabsContent } from '@kandev/ui/tabs'; import { getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from '@/lib/local-storage'; import { ShellTerminal } from '@/components/task/shell-terminal'; -import { useAppStore, useAppStoreApi } from '@/components/state-provider'; +import { PassthroughTerminal } from '@/components/task/passthrough-terminal'; +import { useAppStore } from '@/components/state-provider'; import { useLayoutStore } from '@/lib/state/layout-store'; -import { stopProcess, startProcess, getSessionProcess } from '@/lib/api'; import { useDefaultLayout } from '@/lib/layout/use-default-layout'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts'; +import { useTerminals } from '@/hooks/domains/session/use-terminals'; import type { RepositoryScript } from '@/lib/types/http'; - -type TerminalType = 'commands' | 'dev-server' | 'shell' | 'custom'; - -type Terminal = { - id: string; - type: TerminalType; - label: string; - processId?: string; -}; +import type { Terminal } from '@/hooks/domains/session/use-terminals'; type TaskRightPanelProps = { topPanel: ReactNode; sessionId?: string | null; repositoryId?: string | null; initialScripts?: RepositoryScript[]; + initialTerminals?: Terminal[]; }; const DEFAULT_RIGHT_LAYOUT: Record = { top: 55, bottom: 45 }; -const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null, repositoryId = null, initialScripts = [] }: TaskRightPanelProps) { - const storeApi = useAppStoreApi(); +const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null, repositoryId = null, initialScripts = [], initialTerminals }: TaskRightPanelProps) { const rightPanelIds = ['top', 'bottom']; const rightLayoutKey = 'task-layout-right-v2'; const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({ @@ -43,435 +36,61 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null panelIds: rightPanelIds, baseLayout: DEFAULT_RIGHT_LAYOUT, }); - // Initialize terminals from sessionStorage for persistence across remounts (within browser tab) - const [terminals, setTerminals] = useState(() => { - const baseTerminals: Terminal[] = [{ id: 'shell-1', type: 'shell', label: 'Terminal' }]; - if (!sessionId) return baseTerminals; - const savedCustomTerminals = getSessionStorage(`customTerminals-${sessionId}`, []); - if (savedCustomTerminals.length > 0) { - return [...baseTerminals, ...savedCustomTerminals]; - } - return baseTerminals; - }); + const [isBottomCollapsed, setIsBottomCollapsed] = useState(() => getLocalStorage('task-right-panel-collapsed', false) ); - const [isStoppingDev, setIsStoppingDev] = useState(false); - const activeTab = useAppStore((state) => - sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined - ); + const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab); - - // Track if we've restored the tab from localStorage - const tabRestoredRef = useRef(false); - // Track the previous sessionId to detect changes - const prevSessionIdRef = useRef(sessionId); - // Track which process outputs we've already loaded (to prevent duplicates) - const loadedOutputsRef = useRef>(new Set()); - // Track which process IDs have been explicitly closed (so sync effect doesn't re-add them) - const closedProcessIdsRef = useRef>(new Set()); - - // Track if we've initialized closedProcessIdsRef from sessionStorage - const closedIdsInitializedRef = useRef(false); - - // Reset terminals and refs when sessionId changes - useEffect(() => { - if (prevSessionIdRef.current === sessionId) return; - prevSessionIdRef.current = sessionId; - - // Reset refs - tabRestoredRef.current = false; - loadedOutputsRef.current = new Set(); - closedProcessIdsRef.current = new Set(); - closedIdsInitializedRef.current = false; - - // Reset terminals for new session - const baseTerminals: Terminal[] = [{ id: 'shell-1', type: 'shell', label: 'Terminal' }]; - if (!sessionId) { - setTerminals(baseTerminals); - return; - } - const savedCustomTerminals = getSessionStorage(`customTerminals-${sessionId}`, []); - if (savedCustomTerminals.length > 0) { - setTerminals([...baseTerminals, ...savedCustomTerminals]); - } else { - setTerminals(baseTerminals); - } - }, [sessionId]); - - // Persist active tab to localStorage when it changes - useEffect(() => { - if (!sessionId || !activeTab) return; - setLocalStorage(`rightPanel-tab-${sessionId}`, activeTab); - }, [sessionId, activeTab]); - - // Persist custom terminals to sessionStorage when they change - // Skip persisting during session transitions to avoid overwriting the new session's data - useEffect(() => { - if (!sessionId) return; - // Only persist if we're not in a session transition (prevSessionIdRef is updated in reset effect) - if (prevSessionIdRef.current !== sessionId) return; - const customTerminals = terminals.filter(t => t.type === 'custom'); - setSessionStorage(`customTerminals-${sessionId}`, customTerminals); - }, [sessionId, terminals]); - const closeLayoutPreview = useLayoutStore((state) => state.closePreview); - const setPreviewOpen = useAppStore((state) => state.setPreviewOpen); - const setPreviewStage = useAppStore((state) => state.setPreviewStage); - const devProcessId = useAppStore((state) => - sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined - ); - const devOutput = useAppStore((state) => - devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : '' - ); - const previewOpen = useAppStore((state) => - sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false - ); + + // Use the terminals hook + const { + terminals, + activeTab, + terminalTabValue, + addTerminal, + handleCloseDevTab: baseHandleCloseDevTab, + handleCloseTab, + handleRunCommand, + isStoppingDev, + devProcessId, + devOutput, + } = useTerminals({ sessionId, initialTerminals }); + + // Wrap handleCloseDevTab to also close the layout preview + const handleCloseDevTab = useCallback(async (event: MouseEvent) => { + await baseHandleCloseDevTab(event); + if (sessionId) { + closeLayoutPreview(sessionId); + } + }, [baseHandleCloseDevTab, sessionId, closeLayoutPreview]); // Repository scripts - use initialScripts as fallback for SSR const { scripts: storeScripts, isLoaded: scriptsLoaded } = useRepositoryScripts(repositoryId); const scripts = scriptsLoaded ? storeScripts : (initialScripts.length > 0 ? initialScripts : storeScripts); const hasScripts = scripts.length > 0; - // Restore non-custom tabs from localStorage + // Restore 'commands' tab from sessionStorage (needs hasScripts check) useEffect(() => { - if (!sessionId || tabRestoredRef.current) return; - - const savedTab = getLocalStorage(`rightPanel-tab-${sessionId}`, null); - if (!savedTab) return; - - // Skip custom tabs - they're handled in a separate effect after terminals are set up - if (savedTab.startsWith('custom-')) return; - - // For 'commands' tab, verify scripts exist (either from SSR or loaded) + if (!sessionId || !hasScripts) return; + const savedTab = getSessionStorage(`rightPanel-tab-${sessionId}`, null); if (savedTab === 'commands') { - if (hasScripts) { - setRightPanelActiveTab(sessionId, savedTab); - tabRestoredRef.current = true; - } - return; - } - - // For shell tabs, restore immediately - if (savedTab.startsWith('shell-')) { setRightPanelActiveTab(sessionId, savedTab); - tabRestoredRef.current = true; } }, [sessionId, hasScripts, setRightPanelActiveTab]); - // Process state for custom processes - const processState = useAppStore((state) => state.processes); - const appendProcessOutput = useAppStore((state) => state.appendProcessOutput); - const clearProcessOutput = useAppStore((state) => state.clearProcessOutput); - const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus); - - // Get custom processes for this session from store - const customProcesses = useMemo(() => { - if (!sessionId) return []; - const processIds = processState.processIdsBySessionId[sessionId] ?? []; - return processIds - .map(id => processState.processesById[id]) - .filter(p => p && p.kind === 'custom'); - }, [sessionId, processState.processIdsBySessionId, processState.processesById]); - - // Initialize closedProcessIdsRef from sessionStorage on mount - // This prevents re-adding terminals that were closed before page refresh - useEffect(() => { - if (!sessionId || customProcesses.length === 0) return; - if (closedIdsInitializedRef.current) return; - closedIdsInitializedRef.current = true; - - const savedCustomTerminals = getSessionStorage(`customTerminals-${sessionId}`, []); - const savedProcessIds = new Set( - savedCustomTerminals.map(t => t.processId).filter(Boolean) - ); - - // Any custom process in store that's not in savedTerminals was closed before refresh - customProcesses.forEach(proc => { - if (!savedProcessIds.has(proc.processId)) { - closedProcessIdsRef.current.add(proc.processId); - } - }); - }, [sessionId, customProcesses]); - - // Sync dev server terminal with preview state - useEffect(() => { - if (!sessionId) return; - - setTerminals((prev) => { - const hasDevTerminal = prev.some((t) => t.type === 'dev-server'); - - if (previewOpen && !hasDevTerminal) { - // Add dev server terminal at the beginning - return [{ id: 'dev-server', type: 'dev-server', label: 'Dev Server' }, ...prev]; - } - - if (!previewOpen && hasDevTerminal) { - // Remove dev server terminal - return prev.filter((t) => t.type !== 'dev-server'); - } - - return prev; - }); - }, [previewOpen, sessionId]); - - // Sync custom terminals from store (adds any new processes not already in terminals) - useEffect(() => { - if (!sessionId || customProcesses.length === 0) return; - - setTerminals(prev => { - const existingCustomIds = new Set( - prev.filter(t => t.type === 'custom').map(t => t.processId) - ); - - const newTerminals = customProcesses - .filter(p => !existingCustomIds.has(p.processId) && !closedProcessIdsRef.current.has(p.processId)) - .map(p => ({ - id: `custom-${p.processId}`, - type: 'custom' as const, - label: p.scriptName ?? 'Script', - processId: p.processId, - })); - - if (newTerminals.length === 0) return prev; - return [...prev, ...newTerminals]; - }); - }, [sessionId, customProcesses]); - - // Load output for custom terminals that are no longer running (restore after page refresh) - // Running processes get output via WebSocket streaming - don't fetch from API for those - useEffect(() => { - if (!sessionId) return; - - const customTerminals = terminals.filter(t => t.type === 'custom' && t.processId); - - customTerminals.forEach(async (terminal) => { - const processId = terminal.processId!; - if (loadedOutputsRef.current.has(processId)) return; - - // Check process status - only fetch from API for completed/stopped/failed processes - // Running/starting processes get output via WebSocket streaming - const processInfo = processState.processesById[processId]; - const isActive = !processInfo || processInfo.status === 'running' || processInfo.status === 'starting'; - if (isActive) return; - - // Process is completed/stopped/failed - check if we need to restore output from API - // Get current output from store (don't use as dependency to avoid re-runs on every output change) - const currentOutput = storeApi.getState().processes.outputsByProcessId[processId]; - if (currentOutput && currentOutput.length > 0) { - loadedOutputsRef.current.add(processId); - return; - } - - loadedOutputsRef.current.add(processId); - - try { - const fullProc = await getSessionProcess(sessionId, processId, true); - clearProcessOutput(processId); - if (fullProc.command) { - appendProcessOutput(processId, `$ ${fullProc.command}\n\n`); - } - fullProc.output?.forEach(chunk => { - appendProcessOutput(processId, chunk.data); - }); - } catch { - // Remove from loaded set on error so it can be retried - loadedOutputsRef.current.delete(processId); - } - }); - }, [sessionId, terminals, processState.processesById, clearProcessOutput, appendProcessOutput, storeApi]); - - // Restore saved custom tab after terminals are set up - useEffect(() => { - if (!sessionId || tabRestoredRef.current) return; - - const savedTab = getLocalStorage(`rightPanel-tab-${sessionId}`, null); - if (!savedTab?.startsWith('custom-')) return; - - // Check if this custom tab exists in terminals - const terminalExists = terminals.some(t => t.id === savedTab); - if (terminalExists) { - setRightPanelActiveTab(sessionId, savedTab); - tabRestoredRef.current = true; - } - }, [sessionId, terminals, setRightPanelActiveTab]); - - const addTerminal = useCallback(() => { - setTerminals((prev) => { - const shellTerminals = prev.filter((t) => t.type === 'shell'); - const nextNum = shellTerminals.length + 1; - const newTerminal: Terminal = { - id: `shell-${nextNum}`, - type: 'shell', - label: nextNum === 1 ? 'Terminal' : `Terminal ${nextNum}`, - }; - if (sessionId) { - setRightPanelActiveTab(sessionId, newTerminal.id); - } - return [...prev, newTerminal]; - }); - }, [sessionId, setRightPanelActiveTab]); - - const removeTerminal = useCallback( - (id: string) => { - setTerminals((prev) => { - const terminalToRemove = prev.find((t) => t.id === id); - if (!terminalToRemove) return prev; - - // Only enforce "keep at least one shell" for shell terminals - if (terminalToRemove.type === 'shell') { - const shellTerminals = prev.filter((t) => t.type === 'shell'); - if (shellTerminals.length <= 1) { - return prev; // Keep at least one shell terminal - } - } - - const nextTerminals = prev.filter((t) => t.id !== id); - - // If removing active tab, switch to first available shell terminal - if (activeTab === id) { - const fallbackShell = nextTerminals.find((t) => t.type === 'shell'); - if (fallbackShell && sessionId) { - setRightPanelActiveTab(sessionId, fallbackShell.id); - } - } - - return nextTerminals; - }); - }, - [activeTab, sessionId, setRightPanelActiveTab] - ); - - const terminalTabValue = activeTab ?? terminals.find((t) => t.type === 'shell')?.id ?? 'commands'; - - // Validate active tab exists in terminals + // Persist active tab to sessionStorage when it changes useEffect(() => { if (!sessionId || !activeTab) return; - - const tabExists = - activeTab === 'commands' || - terminals.some((t) => t.id === activeTab); - - if (!tabExists) { - const fallbackShell = terminals.find((t) => t.type === 'shell'); - if (fallbackShell) { - setRightPanelActiveTab(sessionId, fallbackShell.id); - } - } - }, [activeTab, sessionId, terminals, setRightPanelActiveTab]); + setSessionStorage(`rightPanel-tab-${sessionId}`, activeTab); + }, [sessionId, activeTab]); // Save collapse state to local storage useEffect(() => { setLocalStorage('task-right-panel-collapsed', isBottomCollapsed); }, [isBottomCollapsed]); - const handleCloseDevTab = useCallback( - async (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (!sessionId) return; - if (devProcessId) { - setIsStoppingDev(true); - try { - await stopProcess(sessionId, { process_id: devProcessId }); - } finally { - setIsStoppingDev(false); - } - } - const fallbackShell = terminals.find((t) => t.type === 'shell'); - if (fallbackShell) { - setRightPanelActiveTab(sessionId, fallbackShell.id); - } - setPreviewOpen(sessionId, false); - setPreviewStage(sessionId, 'closed'); - closeLayoutPreview(sessionId); - }, - [sessionId, devProcessId, terminals, setRightPanelActiveTab, setPreviewOpen, setPreviewStage, closeLayoutPreview] - ); - - const handleRunCommand = useCallback(async (script: RepositoryScript) => { - if (!sessionId) return; - - try { - const response = await startProcess(sessionId, { - kind: 'custom', - script_name: script.name - }); - - if (response?.process) { - const newTerminal: Terminal = { - id: `custom-${response.process.id}`, - type: 'custom', - label: script.name, - processId: response.process.id, - }; - - // Mark as loaded immediately - WebSocket will stream output, no need to fetch from API - loadedOutputsRef.current.add(response.process.id); - - // Display the command for processes that are still starting/running - // Completed/stopped/failed processes will have output restored by the effect (avoids race condition) - const status = response.process.status; - if (response.process.command && (status === 'starting' || status === 'running')) { - clearProcessOutput(response.process.id); - appendProcessOutput(response.process.id, `$ ${response.process.command}\n\n`); - } - - setTerminals(prev => { - // Check if terminal already exists - if (prev.some(t => t.id === newTerminal.id)) { - return prev; - } - return [...prev, newTerminal]; - }); - setRightPanelActiveTab(sessionId, newTerminal.id); - - // Update store with process status - upsertProcessStatus({ - processId: response.process.id, - sessionId: response.process.session_id, - kind: response.process.kind, - scriptName: response.process.script_name, - status: response.process.status, - command: response.process.command, - workingDir: response.process.working_dir, - exitCode: response.process.exit_code ?? null, - startedAt: response.process.started_at, - updatedAt: response.process.updated_at, - }); - } - } catch (error) { - console.error('Failed to start command:', error); - } - }, [sessionId, setRightPanelActiveTab, upsertProcessStatus, clearProcessOutput, appendProcessOutput]); - - const handleCloseCustomTab = useCallback(async ( - event: MouseEvent, - terminalId: string, - processId?: string - ) => { - event.preventDefault(); - event.stopPropagation(); - - // Mark as closed so sync effect doesn't re-add it - if (processId) { - closedProcessIdsRef.current.add(processId); - } - - // Remove the terminal immediately - removeTerminal(terminalId); - - // Stop process if still running (don't await - do it in background) - if (processId && sessionId) { - const proc = processState.processesById[processId]; - if (proc?.status === 'running' || proc?.status === 'starting') { - stopProcess(sessionId, { process_id: processId }).catch((error) => { - console.error('Failed to stop process:', error); - }); - } - } - }, [sessionId, processState.processesById, removeTerminal]); - const tabs: SessionTab[] = useMemo(() => { const allTabs: SessionTab[] = []; @@ -480,48 +99,25 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null allTabs.push({ id: 'commands', label: 'Commands' }); } - // Add terminal tabs (dev-server, shell, custom) + // Add terminal tabs (dev-server, shell, script) + // Use closable from backend (source of truth) for shell terminals const terminalTabs: SessionTab[] = terminals.map((terminal) => { - const shellCount = terminals.filter((t) => t.type === 'shell').length; - const isClosable = - terminal.type === 'dev-server' || - terminal.type === 'custom' || - (terminal.type === 'shell' && shellCount > 1); - - // Check if custom process is running - let icon: React.ReactNode = undefined; - if (terminal.type === 'custom' && terminal.processId) { - const proc = processState.processesById[terminal.processId]; - const isRunning = proc?.status === 'running' || proc?.status === 'starting'; - if (isRunning) { - icon = ; - } - } - return { id: terminal.id, label: terminal.label, - icon, - className: isClosable - ? 'group flex items-center gap-1 pr-1 cursor-pointer' + className: terminal.closable + ? 'group flex items-center cursor-pointer' : 'cursor-pointer', - closable: isClosable, - alwaysShowClose: terminal.type === 'custom', + closable: terminal.closable, onClose: terminal.type === 'dev-server' ? handleCloseDevTab - : terminal.type === 'custom' - ? (e: MouseEvent) => handleCloseCustomTab(e, terminal.id, terminal.processId) - : (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - removeTerminal(terminal.id); - }, + : (e: MouseEvent) => handleCloseTab(e, terminal.id), }; }); return [...allTabs, ...terminalTabs]; - }, [hasScripts, terminals, processState.processesById, handleCloseDevTab, handleCloseCustomTab, removeTerminal]); + }, [hasScripts, terminals, handleCloseDevTab, handleCloseTab]); if (isBottomCollapsed) { return ( @@ -616,14 +212,13 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null processId={devProcessId ?? null} isStopping={isStoppingDev} /> - ) : terminal.type === 'custom' ? ( - ) : ( - + )} diff --git a/apps/web/hooks/domains/session/use-terminals.ts b/apps/web/hooks/domains/session/use-terminals.ts new file mode 100644 index 0000000000..c5b11e2f4b --- /dev/null +++ b/apps/web/hooks/domains/session/use-terminals.ts @@ -0,0 +1,330 @@ +'use client'; + +import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { getSessionStorage } from '@/lib/local-storage'; +import { useAppStore } from '@/components/state-provider'; +import { stopProcess } from '@/lib/api'; +import { stopUserShell, createUserShell } from '@/lib/api/domains/user-shell-api'; +import { useUserShells } from './use-user-shells'; +import type { RepositoryScript } from '@/lib/types/http'; +import type { MouseEvent } from 'react'; + +export type TerminalType = 'dev-server' | 'shell' | 'script'; + +export type Terminal = { + id: string; + type: TerminalType; + label: string; + closable: boolean; // Whether the terminal can be closed (backend is source of truth) +}; + +interface UseTerminalsOptions { + sessionId: string | null; + initialTerminals?: Terminal[]; // Initial terminals from SSR +} + +interface UseTerminalsReturn { + terminals: Terminal[]; + activeTab: string | undefined; + terminalTabValue: string; + addTerminal: () => void; + removeTerminal: (id: string) => void; + handleCloseDevTab: (event: MouseEvent) => Promise; + handleCloseTab: (event: MouseEvent, terminalId: string) => void; + handleRunCommand: (script: RepositoryScript) => void; + isStoppingDev: boolean; + devProcessId: string | undefined; + devOutput: string; +} + +export function useTerminals({ sessionId, initialTerminals }: UseTerminalsOptions): UseTerminalsReturn { + // Initialize terminals from SSR props if provided, otherwise empty + const [terminals, setTerminals] = useState(() => initialTerminals ?? []); + const [isStoppingDev, setIsStoppingDev] = useState(false); + + // Track refs + const tabRestoredRef = useRef(false); + const prevSessionIdRef = useRef(sessionId); + // Track if sessionId just changed - used to ignore stale store values during transition + const sessionJustChanged = sessionId !== prevSessionIdRef.current; + + // Store selectors + const activeTab = useAppStore((state) => + sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined + ); + const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab); + const devProcessId = useAppStore((state) => + sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined + ); + const devOutput = useAppStore((state) => + devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : '' + ); + const previewOpen = useAppStore((state) => + sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false + ); + const setPreviewOpen = useAppStore((state) => state.setPreviewOpen); + const setPreviewStage = useAppStore((state) => state.setPreviewStage); + + // Use the user shells hook - this fetches running shells from backend + // Backend now returns label and initialCommand for each shell + const { shells: userShells, isLoaded: userShellsLoaded } = useUserShells(sessionId); + + // Reset terminals and refs when sessionId changes + useEffect(() => { + if (prevSessionIdRef.current === sessionId) return; + + prevSessionIdRef.current = sessionId; + + // Reset refs + tabRestoredRef.current = false; + + // Clear terminals - will be populated by user shells effect + setTerminals([]); + + // Clear active tab from store so sessionStorage restoration can set the correct value + // This prevents stale activeTab from store being used when switching back to a session + if (sessionId) { + setRightPanelActiveTab(sessionId, ''); + } + }, [sessionId, setRightPanelActiveTab]); + + // Populate terminals from user shells hook + // Backend is source of truth - always returns at least one "Terminal" entry + useEffect(() => { + if (!sessionId || !userShellsLoaded) return; + + setTerminals(prev => { + // Keep dev-server terminal if it exists + const devTerminal = prev.find(t => t.type === 'dev-server'); + + // Build terminals from backend data, preserving the order returned by backend + // Backend returns them sorted by CreatedAt for stable ordering + const userTerminals: Terminal[] = userShells.map((shell) => { + const isScript = shell.terminalId.startsWith('script-'); + return { + id: shell.terminalId, + type: isScript ? 'script' as const : 'shell' as const, + label: shell.label || (isScript ? 'Script' : 'Terminal'), + closable: shell.closable, + }; + }); + + const result: Terminal[] = []; + if (devTerminal) result.push(devTerminal); + result.push(...userTerminals); + + return result; + }); + }, [sessionId, userShells, userShellsLoaded]); + + // Sync dev server terminal with preview state + useEffect(() => { + if (!sessionId) return; + + setTerminals((prev) => { + const hasDevTerminal = prev.some((t) => t.type === 'dev-server'); + + if (previewOpen && !hasDevTerminal) { + return [{ id: 'dev-server', type: 'dev-server' as const, label: 'Dev Server', closable: true }, ...prev]; + } + + if (!previewOpen && hasDevTerminal) { + return prev.filter((t) => t.type !== 'dev-server'); + } + + return prev; + }); + }, [previewOpen, sessionId]); + + // Sync saved tab to store - useLayoutEffect runs before paint + // This ensures the store has the correct value for subsequent interactions + useLayoutEffect(() => { + // Treat empty string as no active tab (we clear it on session change) + const hasActiveTab = activeTab && activeTab !== ''; + + if (!sessionId || tabRestoredRef.current || hasActiveTab) { + return; + } + + const savedTab = getSessionStorage(`rightPanel-tab-${sessionId}`, null); + if (!savedTab) { + return; + } + + // Restore if the terminal exists + const exists = terminals.some(t => t.id === savedTab); + + if (exists) { + setRightPanelActiveTab(sessionId, savedTab); + tabRestoredRef.current = true; + } + }, [sessionId, terminals, activeTab, setRightPanelActiveTab]); + + // Validate active tab exists in terminals + // IMPORTANT: Only validate after terminals have been loaded for this session + // Skip validation if terminals are empty (still loading) or if tab hasn't been restored yet + useEffect(() => { + if (!sessionId || !activeTab || activeTab === '') return; + + // Skip validation if terminals haven't been loaded yet (avoid validating against stale data) + if (terminals.length === 0) return; + + // Skip validation if we haven't restored from sessionStorage yet + if (!tabRestoredRef.current) return; + + const tabExists = + activeTab === 'commands' || + terminals.some((t) => t.id === activeTab); + + if (!tabExists) { + const fallbackShell = terminals.find((t) => t.type === 'shell'); + if (fallbackShell) { + setRightPanelActiveTab(sessionId, fallbackShell.id); + } + } + }, [activeTab, sessionId, terminals, setRightPanelActiveTab]); + + const addTerminal = useCallback(async () => { + if (!sessionId) return; + + try { + // Ask backend to create terminal - backend assigns ID, label, and closable + const result = await createUserShell(sessionId); + + const newTerminal: Terminal = { + id: result.terminalId, + type: 'shell', + label: result.label, + closable: result.closable, + }; + + setTerminals((prev) => [...prev, newTerminal]); + setRightPanelActiveTab(sessionId, result.terminalId); + } catch (error) { + console.error('Failed to create user shell:', error); + } + }, [sessionId, setRightPanelActiveTab]); + + const removeTerminal = useCallback( + (id: string) => { + setTerminals((prev) => { + const indexToRemove = prev.findIndex((t) => t.id === id); + if (indexToRemove === -1) return prev; + + const terminalToRemove = prev[indexToRemove]; + + // Don't allow removing non-closable terminals (backend is source of truth) + if (!terminalToRemove.closable) { + return prev; + } + + const nextTerminals = prev.filter((t) => t.id !== id); + + // If we're closing the active tab, focus the one to the left (or right if leftmost) + if (activeTab === id && sessionId) { + // Find the terminal to the left, or to the right if there's none on the left + const leftIndex = indexToRemove - 1; + const newFocusTerminal = leftIndex >= 0 ? prev[leftIndex] : nextTerminals[0]; + if (newFocusTerminal) { + setRightPanelActiveTab(sessionId, newFocusTerminal.id); + } + } + + return nextTerminals; + }); + }, + [activeTab, sessionId, setRightPanelActiveTab] + ); + + const handleCloseDevTab = useCallback( + async (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!sessionId) return; + if (devProcessId) { + setIsStoppingDev(true); + try { + await stopProcess(sessionId, { process_id: devProcessId }); + } finally { + setIsStoppingDev(false); + } + } + const fallbackShell = terminals.find((t) => t.type === 'shell'); + if (fallbackShell) { + setRightPanelActiveTab(sessionId, fallbackShell.id); + } + setPreviewOpen(sessionId, false); + setPreviewStage(sessionId, 'closed'); + }, + [sessionId, devProcessId, terminals, setRightPanelActiveTab, setPreviewOpen, setPreviewStage] + ); + + // Handle running a custom command - creates a script terminal via backend + // Backend looks up the script by ID, assigns terminal ID, label, and registers it + const handleRunCommand = useCallback(async (script: RepositoryScript) => { + if (!sessionId) return; + + try { + const result = await createUserShell(sessionId, script.id); + + const newTerminal: Terminal = { + id: result.terminalId, + type: 'script', + label: result.label, + closable: result.closable, + }; + + setTerminals(prev => [...prev, newTerminal]); + setRightPanelActiveTab(sessionId, result.terminalId); + } catch (error) { + console.error('Failed to create script terminal:', error); + } + }, [sessionId, setRightPanelActiveTab]); + + // Handle closing a terminal tab (shell or script) - stops the user shell process + const handleCloseTab = useCallback((event: MouseEvent, terminalId: string) => { + event.preventDefault(); + event.stopPropagation(); + + removeTerminal(terminalId); + + if (sessionId) { + stopUserShell(sessionId, terminalId).catch((error) => { + console.error('Failed to stop terminal:', error); + }); + } + }, [sessionId, removeTerminal]); + + // Read saved tab from sessionStorage synchronously to avoid flicker on initial render + const savedTabFromStorage = useMemo(() => { + if (!sessionId) return null; + return getSessionStorage(`rightPanel-tab-${sessionId}`, null); + }, [sessionId]); + + // Check if saved tab exists in current terminals + const savedTabExists = savedTabFromStorage && terminals.some(t => t.id === savedTabFromStorage); + + // Use saved tab as fallback before store is updated - this prevents flicker + // Trust the saved tab if terminals haven't loaded yet (length === 0) or if it exists + // Treat empty string activeTab as falsy (we clear it on session change) + // IMPORTANT: Ignore store's activeTab if session just changed - it may be stale from before switching away + const effectiveActiveTab = (!sessionJustChanged && activeTab && activeTab !== '') ? activeTab : null; + const terminalTabValue = effectiveActiveTab + ?? (savedTabFromStorage && (terminals.length === 0 || savedTabExists) ? savedTabFromStorage : null) + ?? terminals.find((t) => t.type === 'shell')?.id + ?? 'commands'; + + return { + terminals, + activeTab, + terminalTabValue, + addTerminal, + removeTerminal, + handleCloseDevTab, + handleCloseTab, + handleRunCommand, + isStoppingDev, + devProcessId, + devOutput, + }; +} diff --git a/apps/web/hooks/domains/session/use-user-shells.ts b/apps/web/hooks/domains/session/use-user-shells.ts new file mode 100644 index 0000000000..74724f98d2 --- /dev/null +++ b/apps/web/hooks/domains/session/use-user-shells.ts @@ -0,0 +1,139 @@ +import { useEffect, useRef } from 'react'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { useAppStore, useAppStoreApi } from '@/components/state-provider'; +import type { UserShellInfo } from '@/lib/state/slices'; + +interface UseUserShellsReturn { + shells: UserShellInfo[]; + isLoading: boolean; + isLoaded: boolean; + addShell: (shell: UserShellInfo) => void; + removeShell: (terminalId: string) => void; +} + +const EMPTY_SHELLS: UserShellInfo[] = []; + +/** + * Hook to fetch and manage user shell terminals for a session. + * + * Follows the data fetching pattern: + * 1. Read from store first + * 2. Fetch from backend if not loaded + * 3. Track loading/loaded state + */ +export function useUserShells(sessionId: string | null): UseUserShellsReturn { + const store = useAppStoreApi(); + + // Read from store + const shells = useAppStore((state) => + sessionId ? state.userShells.bySessionId[sessionId] ?? EMPTY_SHELLS : EMPTY_SHELLS + ); + const isLoading = useAppStore((state) => + sessionId ? state.userShells.loading[sessionId] ?? false : false + ); + const isLoaded = useAppStore((state) => + sessionId ? state.userShells.loaded[sessionId] ?? false : false + ); + const connectionStatus = useAppStore((state) => state.connection.status); + + // Guard refs to prevent duplicate fetches + const lastFetchedSessionIdRef = useRef(null); + const prevSessionIdRef = useRef(null); + + // Reset refs when session clears + useEffect(() => { + if (!sessionId) { + lastFetchedSessionIdRef.current = null; + } + }, [sessionId]); + + // Fetch user shells from backend + useEffect(() => { + if (!sessionId) return; + if (connectionStatus !== 'connected') return; + + // Detect session change to force refetch + const sessionChanged = prevSessionIdRef.current !== null && + prevSessionIdRef.current !== sessionId; + prevSessionIdRef.current = sessionId; + + // If session changed, reset fetch guard + if (sessionChanged) { + lastFetchedSessionIdRef.current = null; + } + + // Check if already loaded (unless session just changed) + if (isLoaded && !sessionChanged) { + lastFetchedSessionIdRef.current = sessionId; + return; + } + + // Don't fetch again if already fetched for this session + if (lastFetchedSessionIdRef.current === sessionId) { + return; + } + + const fetchShells = async () => { + const client = getWebSocketClient(); + if (!client) return; + + store.getState().setUserShellsLoading(sessionId, true); + + try { + const response = await client.request<{ shells?: Array<{ + terminal_id: string; + process_id: string; + running: boolean; + label: string; + closable: boolean; + initial_command?: string; + }> }>( + 'user_shell.list', + { session_id: sessionId }, + 10000 + ); + + // Transform backend format (snake_case) to frontend format (camelCase) + const shells: UserShellInfo[] = (response.shells ?? []).map((s) => ({ + terminalId: s.terminal_id, + processId: s.process_id, + running: s.running, + label: s.label || 'Terminal', + closable: s.closable, + initialCommand: s.initial_command, + })); + + store.getState().setUserShells(sessionId, shells); + lastFetchedSessionIdRef.current = sessionId; + } catch (error) { + console.error('Failed to fetch user shells:', error); + // Set empty shells on error so we don't keep retrying + store.getState().setUserShells(sessionId, []); + lastFetchedSessionIdRef.current = sessionId; + } + }; + + fetchShells(); + }, [sessionId, connectionStatus, isLoaded, store]); + + // Actions + const addShell = (shell: UserShellInfo) => { + if (sessionId) { + store.getState().addUserShell(sessionId, shell); + } + }; + + const removeShell = (terminalId: string) => { + if (sessionId) { + store.getState().removeUserShell(sessionId, terminalId); + } + }; + + return { + shells, + isLoading, + isLoaded, + addShell, + removeShell, + }; +} diff --git a/apps/web/hooks/domains/workspace/use-repository-scripts.ts b/apps/web/hooks/domains/workspace/use-repository-scripts.ts index 8656947699..ab37527c84 100644 --- a/apps/web/hooks/domains/workspace/use-repository-scripts.ts +++ b/apps/web/hooks/domains/workspace/use-repository-scripts.ts @@ -41,7 +41,8 @@ export function useRepositoryScripts(repositoryId: string | null, enabled = true if (cancelled) return; setRepositoryScripts(repositoryId, response.scripts ?? []); }) - .catch(() => { + .catch((error) => { + console.error('[useRepositoryScripts] Fetch error:', { repositoryId, error }); if (cancelled) return; setRepositoryScripts(repositoryId, []); }) diff --git a/apps/web/lib/api/domains/user-shell-api.ts b/apps/web/lib/api/domains/user-shell-api.ts new file mode 100644 index 0000000000..4b187d2667 --- /dev/null +++ b/apps/web/lib/api/domains/user-shell-api.ts @@ -0,0 +1,99 @@ +import { getWebSocketClient } from '@/lib/ws/connection'; +import { getBackendConfig } from '@/lib/config'; + +/** + * Terminal info returned by HTTP API (for SSR). + */ +export type TerminalInfo = { + terminal_id: string; + process_id: string; + running: boolean; + label: string; + closable: boolean; + initial_command?: string; +}; + +/** + * Fetch terminals for a session via HTTP (for SSR). + * This endpoint auto-creates the first "Terminal" if none exist. + */ +export async function fetchTerminals(sessionId: string): Promise { + const { apiBaseUrl } = getBackendConfig(); + const url = `${apiBaseUrl}/api/v1/sessions/${sessionId}/terminals`; + try { + const response = await fetch(url); + if (!response.ok) { + console.warn('Failed to fetch terminals:', response.status); + return []; + } + const data = await response.json(); + return data.terminals ?? []; + } catch (error) { + console.warn('Failed to fetch terminals:', error); + return []; + } +} + +/** + * Result of creating a new user shell. + */ +export type CreateUserShellResult = { + terminalId: string; + label: string; + closable: boolean; + initialCommand?: string; +}; + +/** + * Create a new user shell terminal. + * Backend assigns the terminal ID, label, and closable status. + * First terminal is "Terminal" and not closable, subsequent are "Terminal 2", etc. and closable. + * If scriptId is provided, creates a script terminal with the script's name and command. + */ +export async function createUserShell(sessionId: string, scriptId?: string): Promise { + const client = getWebSocketClient(); + if (!client) { + throw new Error('WebSocket client not available'); + } + + const payload: Record = { session_id: sessionId }; + if (scriptId) { + payload.script_id = scriptId; + } + + const response = await client.request('user_shell.create', payload) as { + terminal_id: string; + label: string; + closable: boolean; + initial_command?: string; + }; + + return { + terminalId: response.terminal_id, + label: response.label, + closable: response.closable, + initialCommand: response.initial_command, + }; +} + +/** + * Stop a user shell terminal process. + * This is called when closing a terminal tab to clean up the backend process. + */ +export async function stopUserShell(sessionId: string, terminalId: string): Promise { + const client = getWebSocketClient(); + if (!client) { + // WebSocket not available, silently fail (best-effort cleanup) + return; + } + + try { + await client.request('user_shell.stop', { + session_id: sessionId, + terminal_id: terminalId, + }); + } catch (error) { + // Log but don't throw - this is best-effort cleanup + console.warn('Failed to stop user shell:', error); + } +} diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 87bc88e3f5..7634dc04a2 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -75,6 +75,8 @@ export type { AgentState, AvailableCommand, AvailableCommandsState, + UserShellInfo, + UserShellsState, } from './session-runtime/types'; export type { PreviewStage, diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 70fb159292..90533e469a 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -26,6 +26,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { contextWindow: { bySessionId: {} }, agents: { agents: [] }, availableCommands: { bySessionId: {} }, + userShells: { bySessionId: {}, loading: {}, loaded: {} }, }; export const createSessionRuntimeSlice: StateCreator< @@ -141,4 +142,28 @@ export const createSessionRuntimeSlice: StateCreator< set((draft) => { delete draft.availableCommands.bySessionId[sessionId]; }), + // User shells actions + setUserShells: (sessionId, shells) => + set((draft) => { + draft.userShells.bySessionId[sessionId] = shells; + draft.userShells.loaded[sessionId] = true; + draft.userShells.loading[sessionId] = false; + }), + setUserShellsLoading: (sessionId, loading) => + set((draft) => { + draft.userShells.loading[sessionId] = loading; + }), + addUserShell: (sessionId, shell) => + set((draft) => { + const existing = draft.userShells.bySessionId[sessionId] || []; + // Don't add if already exists + if (!existing.some((s) => s.terminalId === shell.terminalId)) { + draft.userShells.bySessionId[sessionId] = [...existing, shell]; + } + }), + removeUserShell: (sessionId, terminalId) => + set((draft) => { + const existing = draft.userShells.bySessionId[sessionId] || []; + draft.userShells.bySessionId[sessionId] = existing.filter((s) => s.terminalId !== terminalId); + }), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 39526bfdf3..4f401a9f6c 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -146,6 +146,21 @@ export type AvailableCommandsState = { bySessionId: Record; }; +export type UserShellInfo = { + terminalId: string; + processId: string; + running: boolean; + label: string; // Display name (e.g., "Terminal" or script name) + closable: boolean; // Whether the terminal can be closed (first terminal is not closable) + initialCommand?: string; // Command that was run (empty for plain shells) +}; + +export type UserShellsState = { + bySessionId: Record; + loading: Record; + loaded: Record; +}; + export type SessionRuntimeSliceState = { terminal: TerminalState; shell: ShellState; @@ -156,6 +171,7 @@ export type SessionRuntimeSliceState = { contextWindow: ContextWindowState; agents: AgentState; availableCommands: AvailableCommandsState; + userShells: UserShellsState; }; export type SessionRuntimeSliceActions = { @@ -185,6 +201,11 @@ export type SessionRuntimeSliceActions = { // Available commands actions setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; clearAvailableCommands: (sessionId: string) => void; + // User shells actions + setUserShells: (sessionId: string, shells: UserShellInfo[]) => void; + setUserShellsLoading: (sessionId: string, loading: boolean) => void; + addUserShell: (sessionId: string, shell: UserShellInfo) => void; + removeUserShell: (sessionId: string, terminalId: string) => void; }; export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index 2c023ea8a3..10df9995fb 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -49,6 +49,7 @@ export type WorkspaceSliceActions = { setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void; setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void; + clearRepositoryScripts: (repositoryId: string) => void; }; export type WorkspaceSlice = WorkspaceSliceState & WorkspaceSliceActions; diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.ts b/apps/web/lib/state/slices/workspace/workspace-slice.ts index c59cfb8e72..f0dd21382a 100644 --- a/apps/web/lib/state/slices/workspace/workspace-slice.ts +++ b/apps/web/lib/state/slices/workspace/workspace-slice.ts @@ -60,4 +60,10 @@ export const createWorkspaceSlice: StateCreator< set((draft) => { draft.repositoryScripts.loadingByRepositoryId[repositoryId] = loading; }), + clearRepositoryScripts: (repositoryId) => + set((draft) => { + delete draft.repositoryScripts.itemsByRepositoryId[repositoryId]; + delete draft.repositoryScripts.loadingByRepositoryId[repositoryId]; + delete draft.repositoryScripts.loadedByRepositoryId[repositoryId]; + }), }); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 9ecb1de211..2a1413852b 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -83,6 +83,8 @@ export type { ContextWindowEntry, ContextWindowState, AgentState, + UserShellInfo, + UserShellsState, PreviewStage, PreviewViewMode, PreviewDevicePreset, @@ -141,6 +143,7 @@ export type AppState = { contextWindow: typeof defaultSessionRuntimeState['contextWindow']; agents: typeof defaultSessionRuntimeState['agents']; availableCommands: typeof defaultSessionRuntimeState['availableCommands']; + userShells: typeof defaultSessionRuntimeState['userShells']; // UI slice previewPanel: typeof defaultUIState['previewPanel']; @@ -170,6 +173,7 @@ export type AppState = { setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void; setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void; + clearRepositoryScripts: (repositoryId: string) => void; setSettingsData: (next: Partial) => void; setEditors: (editors: EditorsState['items']) => void; setEditorsLoading: (loading: boolean) => void; @@ -254,6 +258,11 @@ export type AppState = { // Available commands actions setAvailableCommands: (sessionId: string, commands: import('./slices/session-runtime/types').AvailableCommand[]) => void; clearAvailableCommands: (sessionId: string) => void; + // User shells actions + setUserShells: (sessionId: string, shells: import('./slices/session-runtime/types').UserShellInfo[]) => void; + setUserShellsLoading: (sessionId: string, loading: boolean) => void; + addUserShell: (sessionId: string, shell: import('./slices/session-runtime/types').UserShellInfo) => void; + removeUserShell: (sessionId: string, terminalId: string) => void; }; export type AppStore = ReturnType; @@ -296,6 +305,7 @@ const defaultState = { contextWindow: defaultSessionRuntimeState.contextWindow, agents: defaultSessionRuntimeState.agents, availableCommands: defaultSessionRuntimeState.availableCommands, + userShells: defaultSessionRuntimeState.userShells, previewPanel: defaultUIState.previewPanel, rightPanel: defaultUIState.rightPanel, diffs: defaultUIState.diffs, @@ -348,6 +358,7 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits }, contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, agents: { ...defaultState.agents, ...initialState.agents }, + userShells: { ...defaultState.userShells, ...initialState.userShells }, previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, diffs: { ...defaultState.diffs, ...initialState.diffs }, @@ -411,6 +422,7 @@ export function createAppStore(initialState?: Partial) { gitStatus: merged.gitStatus, contextWindow: merged.contextWindow, agents: merged.agents, + userShells: merged.userShells, previewPanel: merged.previewPanel, rightPanel: merged.rightPanel, diffs: merged.diffs,