diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index bc5172f612..048d95d73f 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -65,6 +65,11 @@ func provideLifecycleManager( log.Warn("Docker runtime enabled but Docker client not available") } + // Register Remote Docker runtime (always available, instances are created lazily per host) + remoteDockerRuntime := lifecycle.NewRemoteDockerRuntime(log) + runtimeRegistry.Register(remoteDockerRuntime) + log.Info("Remote Docker runtime registered") + agentRegistry, _, err := registry.Provide(log) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 609fd616c2..8fccd2d78f 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -57,6 +57,9 @@ type AgentStreamEventData struct { // comes from a subagent. Used for visual nesting in the UI. ParentToolCallID string `json:"parent_tool_call_id,omitempty"` + // PendingID identifies a permission request (for "permission_cancelled" events). + PendingID string `json:"pending_id,omitempty"` + // Normalized contains the typed tool payload data. // This is used to populate message metadata with structured tool information. Normalized *streams.NormalizedPayload `json:"normalized,omitempty"` diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 029d35ae1d..3d2362b5c6 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -135,6 +135,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even Text: event.Text, ToolCallID: event.ToolCallID, ParentToolCallID: event.ParentToolCallID, + PendingID: event.PendingID, ToolName: event.ToolName, ToolTitle: event.ToolTitle, ToolStatus: event.ToolStatus, diff --git a/apps/backend/internal/agent/lifecycle/runtime_remote_docker.go b/apps/backend/internal/agent/lifecycle/runtime_remote_docker.go new file mode 100644 index 0000000000..00a24792f2 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/runtime_remote_docker.go @@ -0,0 +1,69 @@ +package lifecycle + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" +) + +// RemoteDockerRuntime implements Runtime for remote Docker-based agent execution. +// Unlike the local Docker runtime which bind-mounts the host workspace, the remote +// Docker runtime clones the repository inside the container since the remote host +// does not have access to the local filesystem. +type RemoteDockerRuntime struct { + logger *logger.Logger +} + +// NewRemoteDockerRuntime creates a new remote Docker runtime. +// Remote docker runtimes are created lazily per docker_host when executor configs are resolved. +func NewRemoteDockerRuntime(log *logger.Logger) *RemoteDockerRuntime { + return &RemoteDockerRuntime{ + logger: log.WithFields(zap.String("runtime", "remote_docker")), + } +} + +func (r *RemoteDockerRuntime) Name() runtime.Name { + return runtime.NameRemoteDocker +} + +func (r *RemoteDockerRuntime) HealthCheck(ctx context.Context) error { + // Remote docker health is checked per-host when creating instances. + // The runtime itself is always "available" as a capability. + r.logger.Warn("remote_docker runtime is registered but not yet implemented; health check is a no-op") + return nil +} + +func (r *RemoteDockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRequest) (*RuntimeInstance, error) { + // TODO: Implement remote docker instance creation. + // Flow: + // 1. Extract docker_host, git_token from req.Env or metadata + // 2. Create Docker client pointing to remote host + // 3. Create container with named volume for /workspace (no bind mount) + // 4. Start container, wait for agentctl health + // 5. Clone repo inside container via Docker exec API + // 6. Create agentctl instance pointing to /workspace + // 7. Return RuntimeInstance with container IP + agentctl client + return nil, fmt.Errorf("remote_docker runtime is not yet implemented") +} + +func (r *RemoteDockerRuntime) StopInstance(ctx context.Context, instance *RuntimeInstance, force bool) error { + // TODO: Implement remote docker instance stopping. + // Connect to the remote docker host and stop/kill the container. + return fmt.Errorf("remote_docker runtime is not yet implemented") +} + +func (r *RemoteDockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error) { + // Remote docker instances are not recovered on restart. + // The containers on remote hosts are ephemeral. + return nil, nil +} + +func (r *RemoteDockerRuntime) GetInteractiveRunner() *process.InteractiveRunner { + // Remote docker does not support passthrough mode. + return nil +} diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index bef8f3b993..af7ca33414 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -118,7 +118,8 @@ type LaunchRequest struct { ModelOverride string // If set, use this model instead of the profile's model // Executor configuration - determines which runtime to use - ExecutorType string // Executor type (e.g., "local_pc", "local_docker") - determines runtime + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) // Worktree configuration UseWorktree bool // Whether to use a Git worktree for isolation diff --git a/apps/backend/internal/agent/runtime/runtime.go b/apps/backend/internal/agent/runtime/runtime.go index f25f073b5f..af0d361f43 100644 --- a/apps/backend/internal/agent/runtime/runtime.go +++ b/apps/backend/internal/agent/runtime/runtime.go @@ -9,23 +9,24 @@ import ( type Name string const ( - NameUnknown Name = "" - NameDocker Name = "docker" - NameStandalone Name = "standalone" - NameLocal Name = "local" + NameUnknown Name = "" + NameDocker Name = "docker" + NameStandalone Name = "standalone" + NameLocal Name = "local" + NameRemoteDocker Name = "remote_docker" ) // ExecutorTypeToRuntime maps an ExecutorType to its corresponding runtime Name. func ExecutorTypeToRuntime(execType models.ExecutorType) Name { switch execType { - case models.ExecutorTypeLocalPC: + case models.ExecutorTypeLocal: + return NameStandalone + case models.ExecutorTypeWorktree: return NameStandalone case models.ExecutorTypeLocalDocker: return NameDocker case models.ExecutorTypeRemoteDocker: - // For now, remote docker uses the same runtime as local docker. - // Future: separate runtime for remote docker. - return NameDocker + return NameRemoteDocker default: return NameStandalone } diff --git a/apps/backend/internal/agentctl/server/mcp/server.go b/apps/backend/internal/agentctl/server/mcp/server.go index 3948e1b7b9..39e0d26d84 100644 --- a/apps/backend/internal/agentctl/server/mcp/server.go +++ b/apps/backend/internal/agentctl/server/mcp/server.go @@ -186,7 +186,22 @@ Another example: ] }`), mcp.WithString("prompt", mcp.Required(), mcp.Description("The question to ask the user. Be specific and clear.")), - mcp.WithArray("options", mcp.Required(), mcp.Description(`Array of option objects. Each option must have: "label" (1-5 words, the clickable choice) and "description" (explanation of the option). Provide 2-4 concrete, actionable options.`)), + mcp.WithArray("options", mcp.Required(), mcp.Description(`Array of option objects. Each option must have: "label" (1-5 words, the clickable choice) and "description" (explanation of the option). Provide 2-4 concrete, actionable options.`), + mcp.Items(map[string]any{ + "type": "object", + "properties": map[string]any{ + "label": map[string]any{ + "type": "string", + "description": "Short text (1-5 words) shown as the clickable option", + }, + "description": map[string]any{ + "type": "string", + "description": "Brief explanation of what this option means", + }, + }, + "required": []string{"label", "description"}, + }), + ), mcp.WithString("context", mcp.Description("Optional background information to help the user understand why you're asking this question.")), ), s.askUserQuestionHandler(), diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 99680d6cae..d07f0db315 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -46,7 +46,7 @@ type StartTaskRequest struct { TaskID string `json:"task_id"` SessionID string `json:"session_id,omitempty"` // If provided, resume this existing session instead of creating new AgentProfileID string `json:"agent_profile_id,omitempty"` // Required when creating new session (SessionID not provided) - ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.) + ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local, worktree, local_docker, etc.) Priority int `json:"priority,omitempty"` Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent WorkflowStepID string `json:"workflow_step_id,omitempty"` // If provided, prompt prefix/suffix and plan mode are applied from the step diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index e1fc44e1d8..11404314f0 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -867,6 +867,17 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload)) } + case "permission_cancelled": + // Handle permission cancellation — mark the permission message as expired + if sessionID != "" && payload.Data.PendingID != "" && s.messageCreator != nil { + if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, payload.Data.PendingID, "expired"); err != nil { + s.logger.Warn("failed to mark permission as expired", + zap.String("session_id", sessionID), + zap.String("pending_id", payload.Data.PendingID), + zap.Error(err)) + } + } + case "log": // Handle log events - store agent log messages to the database if sessionID != "" && s.messageCreator != nil { diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 1109f4c257..e52a628f51 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -23,6 +23,7 @@ var ( ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") ErrExecutionNotFound = errors.New("execution not found") ErrExecutionAlreadyRunning = errors.New("execution already running") + ErrRemoteDockerNoRepoURL = errors.New("remote_docker executor requires a repository with provider owner and name set") ) // PromptResult contains the result of a prompt operation @@ -89,7 +90,8 @@ type LaunchAgentRequest struct { Env map[string]string ACPSessionID string // ACP session ID to resume, if available ModelOverride string // If set, use this model instead of the profile's model - ExecutorType string // Executor type (e.g., "local_pc", "local_docker") - determines runtime + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) // Worktree configuration for concurrent agent execution UseWorktree bool // Whether to use a Git worktree for isolation @@ -156,9 +158,8 @@ type Executor struct { logger *logger.Logger // Configuration - retryLimit int - retryDelay time.Duration - worktreeEnabled bool // Whether to use Git worktrees for agent isolation + retryLimit int + retryDelay time.Duration // Per-session locks to prevent concurrent resume/launch operations on the same session. // This prevents race conditions when the backend restarts and multiple resume requests @@ -168,8 +169,7 @@ type Executor struct { // ExecutorConfig holds configuration for the Executor type ExecutorConfig struct { - WorktreeEnabled bool // Whether to use Git worktrees for agent isolation - ShellPrefs ShellPreferenceProvider + ShellPrefs ShellPreferenceProvider } type ShellPreferenceProvider interface { @@ -179,16 +179,41 @@ type ShellPreferenceProvider interface { // NewExecutor creates a new executor func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, log *logger.Logger, cfg ExecutorConfig) *Executor { return &Executor{ - agentManager: agentManager, - repo: repo, - shellPrefs: cfg.ShellPrefs, - logger: log.WithFields(zap.String("component", "executor")), - retryLimit: 3, - retryDelay: 5 * time.Second, - worktreeEnabled: cfg.WorktreeEnabled, + agentManager: agentManager, + repo: repo, + shellPrefs: cfg.ShellPrefs, + logger: log.WithFields(zap.String("component", "executor")), + retryLimit: 3, + retryDelay: 5 * time.Second, } } +// shouldUseWorktree returns true if the given executor type should use Git worktrees. +func shouldUseWorktree(executorType string) bool { + return models.ExecutorType(executorType) == models.ExecutorTypeWorktree +} + +// repositoryCloneURL builds an HTTPS clone URL from the repository's provider info. +// Returns an empty string if the repository has no provider owner/name or if the +// provider is not recognized. +func repositoryCloneURL(repo *models.Repository) string { + if repo.ProviderOwner == "" || repo.ProviderName == "" { + return "" + } + var host string + switch strings.ToLower(repo.Provider) { + case "github", "": + host = "github.com" + case "gitlab": + host = "gitlab.com" + case "bitbucket": + host = "bitbucket.org" + default: + return "" + } + return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) +} + // getSessionLock returns a per-session mutex, creating one if it doesn't exist. // This serializes concurrent resume/launch operations on the same session to prevent // duplicate agent processes after backend restart. @@ -223,7 +248,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, } // ExecuteWithProfile starts agent execution for a task using an explicit agent profile. -// The executorID parameter specifies which executor to use (determines runtime: local_pc, local_docker, etc.). +// The executorID parameter specifies which executor to use (determines runtime: local, worktree, local_docker, etc.). // If executorID is empty, falls back to workspace's default executor. // The prompt parameter is the initial prompt to send to the agent. // The workflowStepID parameter associates the session with a workflow step for transitions. @@ -354,6 +379,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses var baseBranch string var worktreeBranchPrefix string var pullBeforeWorktree bool + var repository *models.Repository // Get the primary repository for this task primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) @@ -369,7 +395,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses baseBranch = primaryTaskRepo.BaseBranch if repositoryID != "" { - repository, err := e.repo.GetRepository(ctx, repositoryID) + repository, err = e.repo.GetRepository(ctx, repositoryID) if err != nil { e.logger.Error("failed to get repository", zap.String("repository_id", repositoryID), @@ -394,19 +420,29 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses SessionID: sessionID, } + // Resolve executor configuration + execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) + if execConfig.ExecutorID != "" { + metadata = execConfig.Metadata + req.ExecutorType = execConfig.ExecutorType + req.ExecutorConfig = execConfig.ExecutorCfg + } + if repositoryPath != "" { - req.UseWorktree = e.worktreeEnabled + req.UseWorktree = shouldUseWorktree(execConfig.ExecutorType) req.RepositoryPath = repositoryPath req.BaseBranch = baseBranch req.WorktreeBranchPrefix = worktreeBranchPrefix req.PullBeforeWorktree = pullBeforeWorktree } - // Resolve executor configuration - execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata) - if execConfig.ExecutorID != "" { - metadata = execConfig.Metadata - req.ExecutorType = execConfig.ExecutorType + // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. + if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker && repository != nil { + cloneURL := repositoryCloneURL(repository) + if cloneURL == "" { + return nil, ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL } if len(metadata) > 0 { @@ -622,11 +658,12 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio var repositoryPath string var worktreeBranchPrefix string var pullBeforeWorktree bool + var repository *models.Repository if repositoryID == "" && len(task.Repositories) > 0 { repositoryID = task.Repositories[0].RepositoryID } if repositoryID != "" { - repository, err := e.repo.GetRepository(ctx, repositoryID) + repository, err = e.repo.GetRepository(ctx, repositoryID) if err != nil { e.logger.Error("failed to load repository for task session resume", zap.String("task_id", task.ID), @@ -650,7 +687,16 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio req.Branch = baseBranch } - if e.worktreeEnabled && repositoryPath != "" { + // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. + if models.ExecutorType(req.ExecutorType) == models.ExecutorTypeRemoteDocker && repository != nil { + cloneURL := repositoryCloneURL(repository) + if cloneURL == "" { + return nil, ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL + } + + if shouldUseWorktree(req.ExecutorType) && repositoryPath != "" { req.UseWorktree = true req.RepositoryPath = repositoryPath req.RepositoryID = repositoryID @@ -998,16 +1044,25 @@ func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel, // Get repository info if available var repositoryPath string if session.RepositoryID != "" { - repository, err := e.repo.GetRepository(ctx, session.RepositoryID) - if err == nil && repository != nil { + repository, repoErr := e.repo.GetRepository(ctx, session.RepositoryID) + if repoErr == nil && repository != nil { repositoryPath = repository.LocalPath req.RepositoryURL = repository.LocalPath req.Branch = session.BaseBranch + + // Remote Docker needs a clone URL instead of a local path. + if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker { + cloneURL := repositoryCloneURL(repository) + if cloneURL == "" { + return nil, ErrRemoteDockerNoRepoURL + } + req.RepositoryURL = cloneURL + } } } - // Configure worktree if enabled - reuse existing worktree from session - if e.worktreeEnabled && repositoryPath != "" { + // Configure worktree if executor type requires it - reuse existing worktree from session + if shouldUseWorktree(execConfig.ExecutorType) && repositoryPath != "" { req.UseWorktree = true req.RepositoryPath = repositoryPath req.RepositoryID = session.RepositoryID @@ -1202,41 +1257,11 @@ func (e *Executor) defaultExecutorID(ctx context.Context, workspaceID string) st return strings.TrimSpace(*workspace.DefaultExecutorID) } -func (e *Executor) applyExecutorMetadata(ctx context.Context, metadata map[string]interface{}, executorID string) map[string]interface{} { - if executorID == "" { - return metadata - } - if metadata == nil { - metadata = map[string]interface{}{} - } - metadata["executor_id"] = executorID - executor, err := e.repo.GetExecutor(ctx, executorID) - if err != nil || executor == nil { - return metadata - } - if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" { - metadata["executor_mcp_policy"] = policyJSON - } - return metadata -} - -// getExecutorType resolves an executor ID to its type string. -// Returns empty string if executor not found or on error. -func (e *Executor) getExecutorType(ctx context.Context, executorID string) string { - if executorID == "" { - return "" - } - executor, err := e.repo.GetExecutor(ctx, executorID) - if err != nil || executor == nil { - return "" - } - return string(executor.Type) -} - // executorConfig holds resolved executor configuration. type executorConfig struct { ExecutorID string ExecutorType string + ExecutorCfg map[string]string // The executor record's Config map (docker_host, etc.) Metadata map[string]interface{} } @@ -1254,13 +1279,28 @@ func (e *Executor) resolveExecutorConfig(ctx context.Context, executorID, worksp metadata = make(map[string]interface{}) } - if resolved != "" { - metadata = e.applyExecutorMetadata(ctx, metadata, resolved) + if resolved == "" { + return executorConfig{Metadata: metadata} + } + + metadata["executor_id"] = resolved + + executor, err := e.repo.GetExecutor(ctx, resolved) + if err != nil || executor == nil { + return executorConfig{ + ExecutorID: resolved, + Metadata: metadata, + } + } + + if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" { + metadata["executor_mcp_policy"] = policyJSON } return executorConfig{ ExecutorID: resolved, - ExecutorType: e.getExecutorType(ctx, resolved), + ExecutorType: string(executor.Type), + ExecutorCfg: executor.Config, Metadata: metadata, } } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 9cbc78f0e0..b3664fd774 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -456,8 +456,7 @@ func newTestExecutor(t *testing.T, agentManager AgentManagerClient, repo *mockRe t.Fatalf("failed to create logger: %v", err) } return NewExecutor(agentManager, repo, log, ExecutorConfig{ - WorktreeEnabled: true, - ShellPrefs: &mockShellPrefs{}, + ShellPrefs: &mockShellPrefs{}, }) } @@ -695,3 +694,77 @@ func TestExecuteWithProfile_UsesPrepareThenLaunch(t *testing.T) { t.Errorf("Expected task ID %s, got %s", task.ID, execution.TaskID) } } + +func TestShouldUseWorktree(t *testing.T) { + tests := []struct { + executorType string + want bool + }{ + {"worktree", true}, + {"local", false}, + {"local_docker", false}, + {"remote_docker", false}, + {"", false}, + } + for _, tt := range tests { + if got := shouldUseWorktree(tt.executorType); got != tt.want { + t.Errorf("shouldUseWorktree(%q) = %v, want %v", tt.executorType, got, tt.want) + } + } +} + +func TestRepositoryCloneURL(t *testing.T) { + tests := []struct { + name string + repo *models.Repository + want string + }{ + { + name: "github repo", + repo: &models.Repository{Provider: "github", ProviderOwner: "acme", ProviderName: "app"}, + want: "https://github.com/acme/app.git", + }, + { + name: "gitlab repo", + repo: &models.Repository{Provider: "gitlab", ProviderOwner: "acme", ProviderName: "app"}, + want: "https://gitlab.com/acme/app.git", + }, + { + name: "bitbucket repo", + repo: &models.Repository{Provider: "bitbucket", ProviderOwner: "acme", ProviderName: "app"}, + want: "https://bitbucket.org/acme/app.git", + }, + { + name: "unknown provider returns empty", + repo: &models.Repository{Provider: "custom", ProviderOwner: "acme", ProviderName: "app"}, + want: "", + }, + { + name: "empty provider defaults to github", + repo: &models.Repository{ProviderOwner: "acme", ProviderName: "app"}, + want: "https://github.com/acme/app.git", + }, + { + name: "missing owner returns empty", + repo: &models.Repository{Provider: "github", ProviderName: "app"}, + want: "", + }, + { + name: "missing name returns empty", + repo: &models.Repository{Provider: "github", ProviderOwner: "acme"}, + want: "", + }, + { + name: "both missing returns empty", + repo: &models.Repository{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := repositoryCloneURL(tt.repo); got != tt.want { + t.Errorf("repositoryCloneURL() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 5be4e219f8..314267a209 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -144,9 +144,7 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo t.Errorf("failed to close repo: %v", err) } }) - cfg := executor.ExecutorConfig{ - WorktreeEnabled: true, - } + cfg := executor.ExecutorConfig{} return executor.NewExecutor(agentMgr, repo, log, cfg) } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index ec4631d8c8..53396faf3b 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -39,17 +39,15 @@ var ( // ServiceConfig holds orchestrator service configuration type ServiceConfig struct { - Scheduler scheduler.SchedulerConfig - QueueSize int - WorktreeEnabled bool // Whether to use Git worktrees for agent isolation + Scheduler scheduler.SchedulerConfig + QueueSize int } // DefaultServiceConfig returns default configuration func DefaultServiceConfig() ServiceConfig { return ServiceConfig{ - Scheduler: scheduler.DefaultSchedulerConfig(), - QueueSize: 1000, - WorktreeEnabled: true, + Scheduler: scheduler.DefaultSchedulerConfig(), + QueueSize: 1000, } } @@ -197,8 +195,7 @@ func NewService( // Create the executor with the agent manager client and repository for persistent sessions execCfg := executor.ExecutorConfig{ - WorktreeEnabled: cfg.WorktreeEnabled, - ShellPrefs: shellPrefs, + ShellPrefs: shellPrefs, } exec := executor.NewExecutor(agentManager, repo, log, execCfg) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 1b99e8223d..e809efdc98 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -644,6 +644,15 @@ func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, // Respond to the permission via agentctl if err := s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled); err != nil { + // Permission likely expired — update message so frontend reflects this + if s.messageCreator != nil { + if updateErr := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, pendingID, "expired"); updateErr != nil { + s.logger.Warn("failed to mark expired permission message", + zap.String("session_id", sessionID), + zap.String("pending_id", pendingID), + zap.Error(updateErr)) + } + } return err } diff --git a/apps/backend/internal/task/handlers/process_handlers.go b/apps/backend/internal/task/handlers/process_handlers.go index 02d7dfe181..69d5900f85 100644 --- a/apps/backend/internal/task/handlers/process_handlers.go +++ b/apps/backend/internal/task/handlers/process_handlers.go @@ -103,7 +103,7 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { } } if executorID == "" { - executorID = models.ExecutorIDLocalPC + executorID = models.ExecutorIDLocal } } executor, err := h.service.GetExecutor(c.Request.Context(), executorID) @@ -210,7 +210,7 @@ func (h *ProcessHandlers) httpStartProcess(c *gin.Context) { } switch executor.Type { - case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: if streams.ProcessKind(kind) == streams.ProcessKindDev { if existing, err := h.lifecycleMgr.ListProcesses(c.Request.Context(), sessionID); err == nil { for _, proc := range existing { @@ -293,7 +293,7 @@ func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) { } } if executorID == "" { - executorID = models.ExecutorIDLocalPC + executorID = models.ExecutorIDLocal } } executor, err := h.service.GetExecutor(c.Request.Context(), executorID) @@ -303,7 +303,7 @@ func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) { } switch executor.Type { - case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, false) if err != nil { handleNotFound(c, h.logger, err, "process not found") @@ -349,7 +349,7 @@ func (h *ProcessHandlers) httpListProcesses(c *gin.Context) { } } if executorID == "" { - executorID = models.ExecutorIDLocalPC + executorID = models.ExecutorIDLocal } } executor, err := h.service.GetExecutor(c.Request.Context(), executorID) @@ -358,7 +358,7 @@ func (h *ProcessHandlers) httpListProcesses(c *gin.Context) { return } switch executor.Type { - case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: listCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() procs, err := h.lifecycleMgr.ListProcesses(listCtx, sessionID) @@ -410,7 +410,7 @@ func (h *ProcessHandlers) httpGetProcess(c *gin.Context) { } } if executorID == "" { - executorID = models.ExecutorIDLocalPC + executorID = models.ExecutorIDLocal } } executor, err := h.service.GetExecutor(c.Request.Context(), executorID) @@ -420,7 +420,7 @@ func (h *ProcessHandlers) httpGetProcess(c *gin.Context) { } includeOutput := c.Query("include_output") == "true" switch executor.Type { - case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker: + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeLocalDocker: proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, includeOutput) if err != nil { handleNotFound(c, h.logger, err, "process not found") diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index a320df2200..43fa69d519 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -524,7 +524,7 @@ func TestStopProcessRejectsDifferentSession(t *testing.T) { "session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"}, }, executors: map[string]*models.Executor{ - "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC}, + "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocal}, }, } svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) @@ -583,7 +583,7 @@ func TestStopProcessAgentctlUnavailable(t *testing.T) { "session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"}, }, executors: map[string]*models.Executor{ - "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC}, + "exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocal}, }, } svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{}) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index ed32bd8270..82c566fd27 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -316,17 +316,16 @@ type RepositoryScript struct { type ExecutorType string const ( - ExecutorTypeLocalPC ExecutorType = "local_pc" - ExecutorTypeLocalDocker ExecutorType = "local_docker" + ExecutorTypeLocal ExecutorType = "local" + ExecutorTypeWorktree ExecutorType = "worktree" + ExecutorTypeLocalDocker ExecutorType = "local_docker" ExecutorTypeRemoteDocker ExecutorType = "remote_docker" - ExecutorTypeRemoteVPS ExecutorType = "remote_vps" - ExecutorTypeK8s ExecutorType = "k8s" ) const ( - ExecutorIDLocalPC = "exec-local-pc" - ExecutorIDLocalDocker = "exec-local-docker" - ExecutorIDRemoteDocker = "exec-remote-docker" + ExecutorIDLocal = "exec-local" + ExecutorIDWorktree = "exec-worktree" + ExecutorIDLocalDocker = "exec-local-docker" ) // ExecutorStatus represents executor availability. diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index 26489c6336..5bebba47cf 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -112,9 +112,18 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { config map[string]string }{ { - id: models.ExecutorIDLocalPC, - name: "Local PC", - execType: models.ExecutorTypeLocalPC, + id: models.ExecutorIDLocal, + name: "Local", + execType: models.ExecutorTypeLocal, + status: models.ExecutorStatusActive, + isSystem: true, + resumable: true, + config: map[string]string{}, + }, + { + id: models.ExecutorIDWorktree, + name: "Worktree", + execType: models.ExecutorTypeWorktree, status: models.ExecutorStatusActive, isSystem: true, resumable: true, @@ -146,10 +155,13 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { } } } else { - if _, err := r.db.ExecContext(ctx, ` - UPDATE executors SET is_system = 1 WHERE id = ? - `, models.ExecutorIDLocalPC); err != nil { - return err + // Ensure system executors have is_system flag set + for _, systemID := range []string{models.ExecutorIDLocal, models.ExecutorIDWorktree} { + if _, err := r.db.ExecContext(ctx, ` + UPDATE executors SET is_system = 1 WHERE id = ? + `, systemID); err != nil { + return err + } } } diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 3152e46627..ca1f7494e7 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -2282,6 +2282,19 @@ func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendin // Publish message.updated event s.publishMessageEvent(ctx, events.MessageUpdated, message) + // When a permission expires, also mark the related tool call as cancelled + // so the UI no longer shows a loading spinner on the tool call. + if status == "expired" { + if toolCallID, ok := message.Metadata["tool_call_id"].(string); ok && toolCallID != "" { + if err := s.UpdateToolCallMessage(ctx, sessionID, toolCallID, "error", "", "", nil); err != nil { + s.logger.Warn("failed to cancel related tool call message", + zap.String("tool_call_id", toolCallID), + zap.String("pending_id", pendingID), + zap.Error(err)) + } + } + } + s.logger.Info("permission message updated", zap.String("message_id", message.ID), zap.String("pending_id", pendingID), diff --git a/apps/packages/ui/src/command.tsx b/apps/packages/ui/src/command.tsx index e2c7652d5a..497a6ebce4 100644 --- a/apps/packages/ui/src/command.tsx +++ b/apps/packages/ui/src/command.tsx @@ -68,7 +68,7 @@ function CommandInput({ }: React.ComponentProps) { return (
- + ({ id: board.id, diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx index a4c6f509d3..2d2844e9e5 100644 --- a/apps/web/app/settings/executor/[id]/page.tsx +++ b/apps/web/app/settings/executor/[id]/page.tsx @@ -2,7 +2,7 @@ import { use, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { IconCpu, IconServer, IconTrash } from '@tabler/icons-react'; +import { IconTrash } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kandev/ui/card'; import { Input } from '@kandev/ui/input'; @@ -21,6 +21,7 @@ import { updateExecutorAction, deleteExecutorAction } from '@/app/actions/execut import { getWebSocketClient } from '@/lib/ws/connection'; import { useAppStore } from '@/components/state-provider'; import type { Executor } from '@/lib/types/http'; +import { getExecutorIcon } from '@/lib/executor-icons'; export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); @@ -61,7 +62,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) { const [isDeleting, setIsDeleting] = useState(false); const isSystem = draft.is_system ?? false; - const isLocalDocker = draft.type === 'local_docker'; + const isDockerType = draft.type === 'local_docker' || draft.type === 'remote_docker'; const mcpPolicyError = useMemo(() => { const value = draft.config?.mcp_policy ?? ''; if (!value.trim()) return null; @@ -77,7 +78,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) { }, [draft.config?.mcp_policy]); const ExecutorIcon = useMemo(() => { - return draft.type === 'local_pc' ? IconCpu : IconServer; + return getExecutorIcon(draft.type); }, [draft.type]); const isDirty = useMemo(() => { @@ -140,11 +141,15 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {

{draft.name}

- {draft.type === 'local_pc' - ? 'Uses locally installed agents on this machine.' + {draft.type === 'local' + ? 'Runs agents directly in the repository folder.' + : draft.type === 'worktree' + ? 'Creates git worktrees for isolated agent sessions.' : draft.type === 'local_docker' ? 'Runs Docker containers on this machine.' - : 'Remote Docker support is coming soon.'} + : draft.type === 'remote_docker' + ? 'Connects to a remote Docker host.' + : 'Custom executor.'}

-
diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index cb8c5c282d..7c5e853612 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -1,12 +1,13 @@ 'use client'; import Link from 'next/link'; -import { IconChevronRight, IconCpu, IconServer } from '@tabler/icons-react'; +import { IconChevronRight, IconBrandDocker, IconCloud } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Card, CardContent } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; import { useAppStore } from '@/components/state-provider'; import type { Executor } from '@/lib/types/http'; +import { getExecutorIcon, getExecutorLabel } from '@/lib/executor-icons'; export default function ExecutorsSettingsPage() { const executors = useAppStore((state) => state.executors.items); @@ -16,7 +17,15 @@ export default function ExecutorsSettingsPage() { label: 'Local Docker', description: 'Run on the local Docker daemon.', href: '/settings/executor/new?type=local_docker', - icon: IconServer, + icon: IconBrandDocker, + enabled: true, + }, + { + id: 'remote_docker', + label: 'Remote Docker', + description: 'Connect to a remote Docker host.', + href: '/settings/executor/new?type=remote_docker', + icon: IconCloud, enabled: true, }, ]; @@ -78,22 +87,9 @@ export default function ExecutorsSettingsPage() {
- {executors - .filter((executor: Executor) => executor.type !== 'remote_docker') - .map((executor: Executor) => { - const Icon = executor.type === 'local_pc' ? IconCpu : IconServer; - const typeLabel = - executor.type === 'local_pc' - ? 'Local PC' - : executor.type === 'local_docker' - ? 'Local Docker' - : executor.type === 'remote_docker' - ? 'Remote Docker' - : executor.type === 'remote_vps' - ? 'Remote Server' - : executor.type === 'k8s' - ? 'K8s' - : 'Remote'; + {executors.map((executor: Executor) => { + const Icon = getExecutorIcon(executor.type); + const typeLabel = getExecutorLabel(executor.type); return ( @@ -115,14 +111,19 @@ export default function ExecutorsSettingsPage() { )}
- {executor.type === 'local_docker' && executor.config?.docker_host && ( + {(executor.type === 'local_docker' || executor.type === 'remote_docker') && executor.config?.docker_host && (
{executor.config.docker_host}
)} - {executor.type === 'local_pc' && ( + {executor.type === 'local' && (
- Launches agents as local processes in the worktree folder set on the Local environment. + Runs agents directly in the repository folder. No worktree isolation. +
+ )} + {executor.type === 'worktree' && ( +
+ Creates a git worktree for each session. Agents work in isolated branches.
)} diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index 7430ef30f4..e596d05af6 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -484,7 +484,7 @@ type CompletionBucket = 'day' | 'week' | 'month'; function CompletedTasksChart({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) { const [bucket, setBucket] = useState('day'); - const safeCompleted = completedActivity ?? []; + const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]); const series = useMemo(() => { if (safeCompleted.length === 0) { @@ -602,7 +602,7 @@ function CompletedTasksChart({ completedActivity }: { completedActivity: Complet } function MostProductiveSummary({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) { - const safeCompleted = completedActivity ?? []; + const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]); const stats = useMemo(() => { if (safeCompleted.length === 0) { diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index f4155b5204..8dfbb64df4 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -1,7 +1,7 @@ "use client" import { memo, useState } from "react" -import { IconCheck, IconSelector } from "@tabler/icons-react" +import { IconCheck, IconChevronDown } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { Button } from "@kandev/ui/button" @@ -37,6 +37,7 @@ interface ComboboxProps { disabled?: boolean className?: string triggerClassName?: string + showSearch?: boolean } export const Combobox = memo(function Combobox({ @@ -50,6 +51,7 @@ export const Combobox = memo(function Combobox({ disabled = false, className, triggerClassName, + showSearch = true, }: ComboboxProps) { const [open, setOpen] = useState(false) @@ -59,7 +61,7 @@ export const Combobox = memo(function Combobox({ - + {dropdownLabel ? (
{dropdownLabel}
) : null} - + {showSearch && } {emptyMessage} diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index c85b778253..ad40d75f67 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -1,11 +1,9 @@ 'use client'; -import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; import { Switch } from '@kandev/ui/switch'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { ModelCombobox } from '@/components/settings/model-combobox'; import type { ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http'; diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index b3a434060d..35347ae9d9 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -218,6 +218,32 @@ export function SettingsAppSidebar() { + {/* Executors */} + + + + + Executors + + + {executors.length > 0 && ( + + {executors.map((executor: Executor) => ( + + + + {executor.name} + + + + ))} + + )} + + {/* Environments */} @@ -243,34 +269,6 @@ export function SettingsAppSidebar() { )} - - {/* Executors */} - - - - - Executors - - - {executors.filter((executor: Executor) => executor.type !== 'remote_docker').length > 0 && ( - - {executors - .filter((executor: Executor) => executor.type !== 'remote_docker') - .map((executor: Executor) => ( - - - - {executor.name} - - - - ))} - - )} - diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index b45ff4a910..b6ee870103 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { IconSettings, IconLoader2 } from '@tabler/icons-react'; +import { IconLoader2, IconGitBranch } from '@tabler/icons-react'; import { Dialog, DialogContent, @@ -16,13 +16,6 @@ import { Label } from '@kandev/ui/label'; import { Input } from '@kandev/ui/input'; import { Textarea } from '@kandev/ui/textarea'; import { Button } from '@kandev/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@kandev/ui/select'; import { Combobox } from './combobox'; import { Badge } from '@kandev/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip'; @@ -49,6 +42,7 @@ import { discoverRepositoriesAction, listLocalRepositoryBranchesAction } from '@ import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { STORAGE_KEYS } from '@/lib/settings/constants'; import { linkToSession } from '@/lib/links'; +import { getExecutorIcon } from '@/lib/executor-icons'; interface TaskCreateDialogProps { open: boolean; @@ -93,6 +87,7 @@ type RepositorySelectorProps = { placeholder: string; searchPlaceholder: string; emptyMessage: string; + triggerClassName?: string; }; const RepositorySelector = memo(function RepositorySelector({ @@ -103,6 +98,7 @@ const RepositorySelector = memo(function RepositorySelector({ placeholder, searchPlaceholder, emptyMessage, + triggerClassName, }: RepositorySelectorProps) { return ( ); }); @@ -192,9 +189,76 @@ const AgentSelector = memo(function AgentSelector({ ); }); +type ExecutorSelectorProps = { + options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>; + value: string; + onValueChange: (value: string) => void; + disabled: boolean; + placeholder: string; + triggerClassName?: string; +}; + +const ExecutorSelector = memo(function ExecutorSelector({ + options, + value, + onValueChange, + disabled, + placeholder, + triggerClassName, +}: ExecutorSelectorProps) { + return ( + + ); +}); + +type InlineTaskNameProps = { + value: string; + onChange: (value: string) => void; + autoFocus?: boolean; +}; + +const InlineTaskName = memo(function InlineTaskName({ + value, + onChange, + autoFocus, +}: InlineTaskNameProps) { + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [autoFocus]); + + return ( + onChange(e.target.value)} + placeholder="task-name" + size={Math.max(value.length, 9)} + className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors" + /> + ); +}); + // Memoized text inputs to prevent re-rendering the entire dialog on every keystroke type TaskFormInputsProps = { isSessionMode: boolean; + showTitleInput: boolean; initialTitle: string; initialDescription: string; onTitleChange: (hasContent: boolean) => void; @@ -206,6 +270,7 @@ type TaskFormInputsProps = { const TaskFormInputs = memo(function TaskFormInputs({ isSessionMode, + showTitleInput, initialTitle, initialDescription, onTitleChange, @@ -265,10 +330,9 @@ const TaskFormInputs = memo(function TaskFormInputs({ return ( <> - {!isSessionMode && ( + {showTitleInput && !isSessionMode && (
)} - {isSessionMode && ( -
- - -
- )}
- {isSessionMode && }