diff --git a/apps/backend/internal/task/dto/converters.go b/apps/backend/internal/task/dto/converters.go index 81ff66911b..631221c112 100644 --- a/apps/backend/internal/task/dto/converters.go +++ b/apps/backend/internal/task/dto/converters.go @@ -16,6 +16,8 @@ func FromWorkflowStep(step *wfmodels.WorkflowStep) WorkflowStepDTO { Color: step.Color, Prompt: step.Prompt, AllowManualMove: step.AllowManualMove, + IsStartStep: step.IsStartStep, + ShowInCommandPanel: step.ShowInCommandPanel, AutoArchiveAfterHours: step.AutoArchiveAfterHours, } if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 97640ca8e6..c754d29aed 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -107,28 +107,30 @@ type EnvironmentDTO struct { } type TaskDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description"` - State v1.TaskState `json:"state"` - Priority int `json:"priority"` - Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` - Position int `json:"position"` - PrimarySessionID *string `json:"primary_session_id,omitempty"` - SessionCount *int `json:"session_count,omitempty"` - ReviewStatus *string `json:"review_status,omitempty"` - PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` - PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` - PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` - PrimarySessionState *string `json:"primary_session_state,omitempty"` - IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority int `json:"priority"` + Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` + Position int `json:"position"` + PrimarySessionID *string `json:"primary_session_id,omitempty"` + SessionCount *int `json:"session_count,omitempty"` + ReviewStatus *string `json:"review_status,omitempty"` + PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` + PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` + PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` + PrimaryAgentName *string `json:"primary_agent_name,omitempty"` + PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"` + PrimarySessionState *string `json:"primary_session_state,omitempty"` + IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type TaskRepositoryDTO struct { @@ -454,7 +456,7 @@ func FromTask(task *models.Task) TaskDTO { // FromTaskWithPrimarySession converts a task model to a TaskDTO, including the primary session ID. func FromTaskWithPrimarySession(task *models.Task, primarySessionID *string) TaskDTO { - return FromTaskWithSessionInfo(task, primarySessionID, nil, nil, nil, nil, nil, nil) + return FromTaskWithSessionInfo(task, primarySessionID, nil, nil, nil, nil, nil, nil, nil, nil) } // FromTaskWithSessionInfo converts a task model to a TaskDTO, including session information. @@ -466,6 +468,8 @@ func FromTaskWithSessionInfo( primaryExecutorID *string, primaryExecutorType *string, primaryExecutorName *string, + primaryAgentName *string, + primaryWorkingDirectory *string, primarySessionState *string, ) TaskDTO { // Convert repositories @@ -484,28 +488,30 @@ func FromTaskWithSessionInfo( } return TaskDTO{ - ID: task.ID, - WorkspaceID: task.WorkspaceID, - WorkflowID: task.WorkflowID, - WorkflowStepID: task.WorkflowStepID, - Title: task.Title, - Description: task.Description, - State: task.State, - Priority: task.Priority, - Repositories: repositories, - Position: task.Position, - PrimarySessionID: primarySessionID, - SessionCount: sessionCount, - ReviewStatus: reviewStatus, - PrimaryExecutorID: primaryExecutorID, - PrimaryExecutorType: primaryExecutorType, - PrimaryExecutorName: primaryExecutorName, - PrimarySessionState: primarySessionState, - IsRemoteExecutor: primaryExecutorType != nil && (*primaryExecutorType == string(models.ExecutorTypeSprites) || *primaryExecutorType == string(models.ExecutorTypeRemoteDocker)), - ArchivedAt: task.ArchivedAt, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - Metadata: task.Metadata, + ID: task.ID, + WorkspaceID: task.WorkspaceID, + WorkflowID: task.WorkflowID, + WorkflowStepID: task.WorkflowStepID, + Title: task.Title, + Description: task.Description, + State: task.State, + Priority: task.Priority, + Repositories: repositories, + Position: task.Position, + PrimarySessionID: primarySessionID, + SessionCount: sessionCount, + ReviewStatus: reviewStatus, + PrimaryExecutorID: primaryExecutorID, + PrimaryExecutorType: primaryExecutorType, + PrimaryExecutorName: primaryExecutorName, + PrimaryAgentName: primaryAgentName, + PrimaryWorkingDirectory: primaryWorkingDirectory, + PrimarySessionState: primarySessionState, + IsRemoteExecutor: primaryExecutorType != nil && (*primaryExecutorType == string(models.ExecutorTypeSprites) || *primaryExecutorType == string(models.ExecutorTypeRemoteDocker)), + ArchivedAt: task.ArchivedAt, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + Metadata: task.Metadata, } } @@ -587,6 +593,8 @@ type WorkflowStepDTO struct { Prompt string `json:"prompt,omitempty"` Events *StepEventsDTO `json:"events,omitempty"` AllowManualMove bool `json:"allow_manual_move"` + IsStartStep bool `json:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 52bff77190..89fbd9c90e 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -113,6 +113,8 @@ func buildTaskDTOsWithSessionInfo(ctx context.Context, svc *service.Service, tas si.executorID, si.executorType, si.executorName, + si.agentName, + si.workingDirectory, si.sessionState, )) } @@ -120,11 +122,13 @@ func buildTaskDTOsWithSessionInfo(ctx context.Context, svc *service.Service, tas } type sessionInfoFields struct { - reviewStatus *string - sessionState *string - executorID *string - executorType *string - executorName *string + reviewStatus *string + sessionState *string + executorID *string + executorType *string + executorName *string + agentName *string + workingDirectory *string } func extractSessionInfo(info *models.TaskSession) sessionInfoFields { @@ -149,6 +153,16 @@ func extractSessionInfo(info *models.TaskSession) sessionInfoFields { si.executorName = &n } } + if info.AgentProfileSnapshot != nil { + if name, ok := info.AgentProfileSnapshot["name"].(string); ok && name != "" { + si.agentName = &name + } + } + if info.RepositorySnapshot != nil { + if path, ok := info.RepositorySnapshot["path"].(string); ok && path != "" { + si.workingDirectory = &path + } + } return si } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 72d355c064..f31b16f224 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -904,7 +904,7 @@ func (r *Repository) GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []st } // GetPrimarySessionInfoByTaskIDs returns a map of task ID to primary session for the given task IDs. -// Only returns the review_status field from the session. +// Returns review_status, executor info, agent profile snapshot, and repository snapshot. func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) { if len(taskIDs) == 0 { return make(map[string]*models.TaskSession), nil @@ -919,7 +919,9 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs } query := fmt.Sprintf(` - SELECT ts.task_id, ts.review_status, ts.executor_id, ts.state, e.type, e.name + SELECT ts.task_id, ts.review_status, ts.executor_id, ts.state, + ts.agent_profile_snapshot, ts.repository_snapshot, + e.type, e.name FROM task_sessions ts LEFT JOIN executors e ON e.id = ts.executor_id WHERE ts.task_id IN (%s) AND ts.is_primary = 1 @@ -937,9 +939,11 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs var reviewStatus sql.NullString var executorID sql.NullString var sessionState sql.NullString + var agentProfileSnapshotJSON sql.NullString + var repositorySnapshotJSON sql.NullString var executorType sql.NullString var executorName sql.NullString - if err := rows.Scan(&taskID, &reviewStatus, &executorID, &sessionState, &executorType, &executorName); err != nil { + if err := rows.Scan(&taskID, &reviewStatus, &executorID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil { return nil, err } session := &models.TaskSession{ @@ -963,6 +967,16 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs session.ExecutorSnapshot["executor_name"] = executorName.String } } + if agentProfileSnapshotJSON.Valid && agentProfileSnapshotJSON.String != "" { + if err := json.Unmarshal([]byte(agentProfileSnapshotJSON.String), &session.AgentProfileSnapshot); err != nil { + return nil, fmt.Errorf("failed to unmarshal agent profile snapshot for task %s: %w", taskID, err) + } + } + if repositorySnapshotJSON.Valid && repositorySnapshotJSON.String != "" { + if err := json.Unmarshal([]byte(repositorySnapshotJSON.String), &session.RepositorySnapshot); err != nil { + return nil, fmt.Errorf("failed to unmarshal repository snapshot for task %s: %w", taskID, err) + } + } result[taskID] = session } return result, rows.Err() diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index dfc2b8e064..c4ddd7172f 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -62,6 +62,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS SavedLayouts: req.SavedLayouts, DefaultUtilityAgentID: req.DefaultUtilityAgentID, DefaultUtilityModel: req.DefaultUtilityModel, + KeyboardShortcuts: req.KeyboardShortcuts, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 7c45e9fe08..7d0c2f84a7 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -33,6 +33,7 @@ type UserSettingsDTO struct { SavedLayouts []models.SavedLayout `json:"saved_layouts"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` UpdatedAt string `json:"updated_at"` } @@ -70,6 +71,7 @@ type UpdateUserSettingsRequest struct { SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` DefaultUtilityModel *string `json:"default_utility_model,omitempty"` + KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -102,6 +104,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { SavedLayouts: settings.SavedLayouts, DefaultUtilityAgentID: settings.DefaultUtilityAgentID, DefaultUtilityModel: settings.DefaultUtilityModel, + KeyboardShortcuts: settings.KeyboardShortcuts, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 18796b950b..aeebfce210 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -32,6 +32,7 @@ type UserSettings struct { SavedLayouts []SavedLayout `json:"saved_layouts"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 1709b70392..861268f352 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -44,6 +44,7 @@ type UpdateUserSettingsRequest struct { SavedLayouts *[]models.SavedLayout DefaultUtilityAgentID *string DefaultUtilityModel *string + KeyboardShortcuts *map[string]interface{} } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -154,6 +155,12 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.DefaultUtilityModel != nil { settings.DefaultUtilityModel = strings.TrimSpace(*req.DefaultUtilityModel) } + if req.KeyboardShortcuts != nil { + if err := validateKeyboardShortcuts(*req.KeyboardShortcuts); err != nil { + return err + } + settings.KeyboardShortcuts = *req.KeyboardShortcuts + } return nil } @@ -235,6 +242,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "saved_layouts": settings.SavedLayouts, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, + "keyboard_shortcuts": settings.KeyboardShortcuts, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { @@ -242,6 +250,31 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models } } +func validateKeyboardShortcuts(shortcuts map[string]interface{}) error { + for name, raw := range shortcuts { + shortcut, ok := raw.(map[string]interface{}) + if !ok { + return fmt.Errorf("keyboard_shortcuts.%s must be an object", name) + } + key, ok := shortcut["key"].(string) + if !ok || strings.TrimSpace(key) == "" { + return fmt.Errorf("keyboard_shortcuts.%s.key must be a non-empty string", name) + } + if modsRaw, exists := shortcut["modifiers"]; exists { + mods, ok := modsRaw.(map[string]interface{}) + if !ok { + return fmt.Errorf("keyboard_shortcuts.%s.modifiers must be an object", name) + } + for mod, v := range mods { + if _, ok := v.(bool); !ok { + return fmt.Errorf("keyboard_shortcuts.%s.modifiers.%s must be a boolean", name, mod) + } + } + } + } + return nil +} + func validateLSPLanguages(langs []string) error { supported := installer.SupportedLanguages() for _, lang := range langs { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 09deac4ffa..f0f2100483 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -129,6 +129,10 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod if savedLayouts == nil { savedLayouts = []models.SavedLayout{} } + keyboardShortcuts := settings.KeyboardShortcuts + if keyboardShortcuts == nil { + keyboardShortcuts = map[string]interface{}{} + } settingsPayload, err := json.Marshal(map[string]interface{}{ "workspace_id": settings.WorkspaceID, "kanban_view_mode": settings.KanbanViewMode, @@ -148,6 +152,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod "saved_layouts": savedLayouts, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, + "keyboard_shortcuts": keyboardShortcuts, }) if err != nil { return err @@ -190,6 +195,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.ShowReleaseNotification = true settings.ReviewAutoMarkOnScroll = true settings.ChatSubmitKey = "cmd_enter" + settings.KeyboardShortcuts = map[string]interface{}{} return settings, nil } var payload struct { @@ -211,6 +217,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin SavedLayouts []models.SavedLayout `json:"saved_layouts"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -255,5 +262,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel + settings.KeyboardShortcuts = payload.KeyboardShortcuts + if settings.KeyboardShortcuts == nil { + settings.KeyboardShortcuts = map[string]interface{}{} + } return settings, nil } diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index 73eebd8ddc..94cbf516fe 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -93,14 +93,15 @@ func (c *Controller) CreateStepsFromTemplate(ctx context.Context, req CreateStep // CreateStepRequest is the request for creating a single workflow step. type CreateStepRequest struct { - WorkflowID string `json:"workflow_id"` - Name string `json:"name"` - Position int `json:"position"` - Color string `json:"color"` - Prompt string `json:"prompt,omitempty"` - Events *models.StepEvents `json:"events,omitempty"` - AllowManualMove bool `json:"allow_manual_move"` - IsStartStep *bool `json:"is_start_step,omitempty"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events *models.StepEvents `json:"events,omitempty"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep *bool `json:"is_start_step,omitempty"` + ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"` } // CreateStep creates a new workflow step. @@ -119,6 +120,11 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge if req.IsStartStep != nil { step.IsStartStep = *req.IsStartStep } + if req.ShowInCommandPanel != nil { + step.ShowInCommandPanel = *req.ShowInCommandPanel + } else { + step.ShowInCommandPanel = true // default to visible + } if err := c.svc.CreateStep(ctx, step); err != nil { return nil, err } @@ -135,6 +141,7 @@ type UpdateStepRequest struct { Events *models.StepEvents `json:"events,omitempty"` AllowManualMove *bool `json:"allow_manual_move,omitempty"` IsStartStep *bool `json:"is_start_step,omitempty"` + ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"` AutoArchiveAfterHours *int `json:"auto_archive_after_hours,omitempty"` } @@ -165,6 +172,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.IsStartStep != nil { step.IsStartStep = *req.IsStartStep } + if req.ShowInCommandPanel != nil { + step.ShowInCommandPanel = *req.ShowInCommandPanel + } if req.AutoArchiveAfterHours != nil { step.AutoArchiveAfterHours = *req.AutoArchiveAfterHours } diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index 75f57eea1d..0831e9a5c3 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -34,6 +34,7 @@ type StepPortable struct { Prompt string `json:"prompt,omitempty"` Events StepEvents `json:"events"` IsStartStep bool `json:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel"` AllowManualMove bool `json:"allow_manual_move"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` } @@ -68,6 +69,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep) Workf Prompt: s.Prompt, Events: convertStepIDToPosition(s.Events, idToPos), IsStartStep: s.IsStartStep, + ShowInCommandPanel: s.ShowInCommandPanel, AllowManualMove: s.AllowManualMove, AutoArchiveAfterHours: s.AutoArchiveAfterHours, }) diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go index 1eec1a3b15..8ba065ab43 100644 --- a/apps/backend/internal/workflow/models/export_test.go +++ b/apps/backend/internal/workflow/models/export_test.go @@ -335,6 +335,26 @@ func TestRoundTrip(t *testing.T) { }) } +func TestShowInCommandPanelExport(t *testing.T) { + t.Run("preserves show_in_command_panel in export", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "Test"} + steps := []*WorkflowStep{ + {ID: "s1", Name: "Backlog", Position: 0, ShowInCommandPanel: false}, + {ID: "s2", Name: "Active", Position: 1, ShowInCommandPanel: true}, + {ID: "s3", Name: "Done", Position: 2, ShowInCommandPanel: false}, + } + export := BuildWorkflowExport( + []*taskmodels.Workflow{wf}, + map[string][]*WorkflowStep{"wf-1": steps}, + ) + + require.Len(t, export.Workflows[0].Steps, 3) + assert.False(t, export.Workflows[0].Steps[0].ShowInCommandPanel) + assert.True(t, export.Workflows[0].Steps[1].ShowInCommandPanel) + assert.False(t, export.Workflows[0].Steps[2].ShowInCommandPanel) + }) +} + func TestToInt(t *testing.T) { t.Run("float64", func(t *testing.T) { v, ok := toInt(float64(42)) diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index 28852817d1..4d310cc701 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -102,6 +102,7 @@ type StepDefinition struct { Events StepEvents `json:"events"` AllowManualMove bool `json:"allow_manual_move"` IsStartStep bool `json:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` } @@ -116,6 +117,7 @@ type WorkflowStep struct { Events StepEvents `json:"events"` AllowManualMove bool `json:"allow_manual_move"` IsStartStep bool `json:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index fdc14440f1..4b03eb2e2c 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -60,6 +60,7 @@ func (r *Repository) initSchema() error { events TEXT, allow_manual_move INTEGER DEFAULT 1, is_start_step INTEGER DEFAULT 0, + show_in_command_panel INTEGER DEFAULT 1, auto_archive_after_hours INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, @@ -96,6 +97,9 @@ func (r *Repository) initSchema() error { return fmt.Errorf("failed to create session_step_history table: %w", err) } + // Add show_in_command_panel column if it doesn't exist (idempotent migration) + _, _ = r.db.Exec(`ALTER TABLE workflow_steps ADD COLUMN show_in_command_panel INTEGER DEFAULT 1`) + // Seed system templates if err := r.seedSystemTemplates(); err != nil { return fmt.Errorf("failed to seed system templates: %w", err) @@ -156,12 +160,12 @@ func (r *Repository) seedDefaultWorkflowSteps() error { if _, err := r.db.Exec(r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(eventsJSON), dialect.BoolToInt(stepDef.AllowManualMove), - dialect.BoolToInt(stepDef.IsStartStep), now, now, + dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel), now, now, ); err != nil { return err } @@ -230,12 +234,13 @@ func (r *Repository) getStandardTemplate(now time.Time) *models.WorkflowTemplate AllowManualMove: true, }, { - ID: "plan", - Name: "Plan", - Position: 1, - Color: "bg-purple-500", - IsStartStep: true, - Prompt: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", + ID: "plan", + Name: "Plan", + Position: 1, + Color: "bg-purple-500", + IsStartStep: true, + ShowInCommandPanel: true, + Prompt: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nWhen including diagrams in your plan (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterEnablePlanMode}, @@ -245,11 +250,12 @@ func (r *Repository) getStandardTemplate(now time.Time) *models.WorkflowTemplate AllowManualMove: true, }, { - ID: "implementation", - Name: "Implementation", - Position: 2, - Color: "bg-blue-500", - Prompt: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.\n\n{{task_prompt}}", + ID: "implementation", + Name: "Implementation", + Position: 2, + Color: "bg-blue-500", + ShowInCommandPanel: true, + Prompt: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.\n\n{{task_prompt}}", Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterAutoStartAgent}, @@ -291,12 +297,13 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp AllowManualMove: true, }, { - ID: "planning", - Name: "Planning", - Position: 1, - Color: "bg-purple-500", - IsStartStep: true, - Prompt: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nWhen including diagrams in your design (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", + ID: "planning", + Name: "Planning", + Position: 1, + Color: "bg-purple-500", + IsStartStep: true, + ShowInCommandPanel: true, + Prompt: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nWhen including diagrams in your design (architecture, sequence, flowcharts, etc.), always use mermaid syntax in code blocks.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.\n\n{{task_prompt}}", Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterEnablePlanMode}, @@ -310,10 +317,11 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp AllowManualMove: true, }, { - ID: "review", - Name: "Review", - Position: 2, - Color: "bg-yellow-500", + ID: "review", + Name: "Review", + Position: 2, + Color: "bg-yellow-500", + ShowInCommandPanel: true, Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterEnablePlanMode}, @@ -362,10 +370,11 @@ func (r *Repository) getKanbanTemplate(now time.Time) *models.WorkflowTemplate { AllowManualMove: true, }, { - ID: "in-progress", - Name: "In Progress", - Position: 1, - Color: "bg-blue-500", + ID: "in-progress", + Name: "In Progress", + Position: 1, + Color: "bg-blue-500", + ShowInCommandPanel: true, Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterAutoStartAgent}, @@ -378,10 +387,11 @@ func (r *Repository) getKanbanTemplate(now time.Time) *models.WorkflowTemplate { IsStartStep: true, }, { - ID: "review", - Name: "Review", - Position: 2, - Color: "bg-yellow-500", + ID: "review", + Name: "Review", + Position: 2, + Color: "bg-yellow-500", + ShowInCommandPanel: true, Events: models.StepEvents{ OnTurnStart: []models.OnTurnStartAction{ {Type: models.OnTurnStartMoveToPrevious}, @@ -429,11 +439,12 @@ func (r *Repository) getPRReviewTemplate(now time.Time) *models.WorkflowTemplate AllowManualMove: true, }, { - ID: "review", - Name: "Review", - Position: 1, - Color: "bg-yellow-500", - Prompt: "Please review the changed files in the current git worktree.\n\nSTEP 1: Determine what to review\n- First, check if there are any uncommitted changes (dirty working directory)\n- If there are uncommitted/staged changes: review those files\n- If the working directory is clean: review the commits that have diverged from the master/main branch\n\nSTEP 2: Review the changes, then output your findings in EXACTLY 4 sections: BUG, IMPROVEMENT, NITPICK, PERFORMANCE.\n\nRules:\n- Each section is OPTIONAL — only include it if you have findings for that category\n- If a section has no findings, omit it entirely\n- Format each finding as: filename:line_number - Description\n- Be specific and reference exact line numbers\n- Keep descriptions concise but actionable\n\n{{task_prompt}}", + ID: "review", + Name: "Review", + Position: 1, + Color: "bg-yellow-500", + ShowInCommandPanel: true, + Prompt: "Please review the changed files in the current git worktree.\n\nSTEP 1: Determine what to review\n- First, check if there are any uncommitted changes (dirty working directory)\n- If there are uncommitted/staged changes: review those files\n- If the working directory is clean: review the commits that have diverged from the master/main branch\n\nSTEP 2: Review the changes, then output your findings in EXACTLY 4 sections: BUG, IMPROVEMENT, NITPICK, PERFORMANCE.\n\nRules:\n- Each section is OPTIONAL — only include it if you have findings for that category\n- If a section has no findings, omit it entirely\n- Format each finding as: filename:line_number - Description\n- Be specific and reference exact line numbers\n- Keep descriptions concise but actionable\n\n{{task_prompt}}", Events: models.StepEvents{ OnEnter: []models.OnEnterAction{ {Type: models.OnEnterAutoStartAgent}, @@ -627,11 +638,11 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), step.ID, step.WorkflowID, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove), - dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) + dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) return err } @@ -641,12 +652,12 @@ func (r *Repository) scanStep(row interface { Scan(dest ...interface{}) error }) (*models.WorkflowStep, error) { step := &models.WorkflowStep{} - var allowManualMove, isStartStep int + var allowManualMove, isStartStep, showInCommandPanel int var autoArchiveAfterHours sql.NullInt64 var color, prompt, eventsJSON sql.NullString err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, - &prompt, &eventsJSON, &allowManualMove, &isStartStep, &autoArchiveAfterHours, &step.CreatedAt, &step.UpdatedAt) + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &step.CreatedAt, &step.UpdatedAt) if err != nil { return nil, err @@ -654,6 +665,7 @@ func (r *Repository) scanStep(row interface { step.AllowManualMove = allowManualMove == 1 step.IsStartStep = isStartStep == 1 + step.ShowInCommandPanel = showInCommandPanel == 1 if autoArchiveAfterHours.Valid { step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64) } @@ -672,7 +684,7 @@ func (r *Repository) scanStep(row interface { return step, nil } -const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at` +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, created_at, updated_at` // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { @@ -704,11 +716,11 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, - allow_manual_move = ?, is_start_step = ?, auto_archive_after_hours = ?, updated_at = ? + allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, updated_at = ? WHERE id = ? `), step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) + dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) if err != nil { return err } @@ -811,7 +823,7 @@ func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) func (r *Repository) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ws.id, ws.workflow_id, ws.name, ws.position, ws.color, ws.prompt, ws.events, - ws.allow_manual_move, ws.is_start_step, ws.auto_archive_after_hours, ws.created_at, ws.updated_at + ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.created_at, ws.updated_at FROM workflow_steps ws JOIN workflows w ON ws.workflow_id = w.id WHERE w.workspace_id = ? diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index 8d4bf9012f..e46f04a14d 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -449,6 +449,7 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, Prompt: sp.Prompt, Events: models.ConvertPositionToStepID(sp.Events, posToID), IsStartStep: sp.IsStartStep, + ShowInCommandPanel: sp.ShowInCommandPanel, AllowManualMove: sp.AllowManualMove, AutoArchiveAfterHours: sp.AutoArchiveAfterHours, } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 5fffad897c..cdeca58100 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -570,6 +570,38 @@ func TestImportWorkflows(t *testing.T) { assert.Nil(t, stepB.Events.OnTurnComplete[0].Config["step_position"]) }) + t.Run("preserves show_in_command_panel on import", func(t *testing.T) { + svc, _, _ := setupTestServiceWithProvider(t) + ctx := context.Background() + + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{ + { + Name: "CmdPanel WF", + Steps: []models.StepPortable{ + {Name: "Backlog", Position: 0, Color: "gray", ShowInCommandPanel: false}, + {Name: "Active", Position: 1, Color: "blue", ShowInCommandPanel: true}, + {Name: "Done", Position: 2, Color: "green", ShowInCommandPanel: false}, + }, + }, + }, + } + + result, err := svc.ImportWorkflows(ctx, "ws-1", export) + require.NoError(t, err) + require.Len(t, result.Created, 1) + + steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-CmdPanel WF") + require.NoError(t, err) + require.Len(t, steps, 3) + + assert.False(t, steps[0].ShowInCommandPanel, "Backlog should not show in command panel") + assert.True(t, steps[1].ShowInCommandPanel, "Active should show in command panel") + assert.False(t, steps[2].ShowInCommandPanel, "Done should not show in command panel") + }) + t.Run("rejects invalid export data", func(t *testing.T) { svc, _, _ := setupTestServiceWithProvider(t) ctx := context.Background() diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 47c85a754a..706099bf22 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -272,6 +272,8 @@ type BackendTemplateStep = { color?: string; prompt?: string; events?: StepEvents; + is_start_step?: boolean; + show_in_command_panel?: boolean; }; type BackendWorkflowTemplate = Omit & { @@ -287,6 +289,8 @@ const normalizeWorkflowTemplate = (template: BackendWorkflowTemplate): WorkflowT color: step.color, prompt: step.prompt, events: step.events, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, })); return { ...template, @@ -317,6 +321,7 @@ type BackendWorkflowStep = { events?: StepEvents; allow_manual_move?: boolean; is_start_step?: boolean; + show_in_command_panel?: boolean; auto_archive_after_hours?: number; created_at: string; updated_at: string; @@ -332,6 +337,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ events: step.events, allow_manual_move: step.allow_manual_move, is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, auto_archive_after_hours: step.auto_archive_after_hours, created_at: step.created_at, updated_at: step.updated_at, @@ -402,6 +408,7 @@ export async function updateWorkflowStepAction( | "events" | "allow_manual_move" | "is_start_step" + | "show_in_command_panel" | "auto_archive_after_hours" > >, @@ -414,6 +421,8 @@ export async function updateWorkflowStepAction( if (payload.events !== undefined) body.events = payload.events; if (payload.allow_manual_move !== undefined) body.allow_manual_move = payload.allow_manual_move; if (payload.is_start_step !== undefined) body.is_start_step = payload.is_start_step; + if (payload.show_in_command_panel !== undefined) + body.show_in_command_panel = payload.show_in_command_panel; if (payload.auto_archive_after_hours !== undefined) body.auto_archive_after_hours = payload.auto_archive_after_hours; const response = await fetchJson( diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index c4c722ef0d..54a6db9a5b 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -64,6 +64,7 @@ function buildWorkflowSteps(workflow: Workflow, definitions: StepDefinition[]): prompt: step.prompt, events: step.events, is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, allow_manual_move: true, created_at: "", updated_at: "", diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index b939018418..ca7b002340 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -1,7 +1,7 @@ "use client"; import { formatDistanceToNow } from "date-fns"; -import { IconArrowRight, IconLoader2, IconSearch } from "@tabler/icons-react"; +import { IconArchive, IconArrowRight, IconHammer, IconLoader2 } from "@tabler/icons-react"; import { Command, CommandDialog, @@ -16,11 +16,26 @@ import { Kbd, KbdGroup } from "@kandev/ui/kbd"; import { Badge } from "@kandev/ui/badge"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; import { formatShortcut } from "@/lib/keyboard/utils"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { useAppStore } from "@/components/state-provider"; import type { Task } from "@/lib/types/http"; import { FileIcon } from "@/components/ui/file-icon"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); -const MODE_SEARCH_TASKS: CommandPanelMode = "search-tasks"; +const MODE_COMMANDS: CommandPanelMode = "commands"; +const MODE_SEARCH_FILES: CommandPanelMode = "search-files"; + +const STEP_COLOR_MAP: Record = { + "bg-slate-500": "#64748b", + "bg-red-500": "#ef4444", + "bg-orange-500": "#f97316", + "bg-yellow-500": "#eab308", + "bg-green-500": "#22c55e", + "bg-cyan-500": "#06b6d4", + "bg-blue-500": "#3b82f6", + "bg-indigo-500": "#6366f1", + "bg-purple-500": "#a855f7", +}; function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; @@ -54,38 +69,52 @@ function CommandItemRow({ type TaskResultItemProps = { task: Task; stepMap: Map; + repoMap: Map; onSelect: (task: Task) => void; }; -function TaskResultItem({ task, stepMap, onSelect }: TaskResultItemProps) { +function TaskResultItem({ task, stepMap, repoMap, onSelect }: TaskResultItemProps) { const isArchived = ARCHIVED_STATES.has(task.state); const step = stepMap.get(task.workflow_step_id); + const stepHex = step ? STEP_COLOR_MAP[step.color] : undefined; + const rawPath = + task.primary_working_directory ?? + (task.repositories?.[0] ? repoMap.get(task.repositories[0].repository_id) : undefined); + const workDir = rawPath ? getFileName(rawPath) : undefined; + const details: string[] = []; + if (workDir) details.push(workDir); + if (task.primary_agent_name) details.push(task.primary_agent_name); + if (task.updated_at) { + details.push(formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })); + } return ( onSelect(task)} className={isArchived ? "opacity-60" : ""} + forceMount > -
-
- - {task.title} - {step && ( - - {step.name} - - )} - {isArchived && ( - - {task.state} - - )} -
- {task.updated_at && ( -
- {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} -
+
+ {isArchived ? ( + + ) : ( + + )} + {task.title} + {step && ( + + {step.name} + + )} + {details.length > 0 && ( + + {details.join(" · ")} + )}
@@ -97,7 +126,11 @@ type CommandsListContentProps = { grouped: [string, CommandItemType[]][]; search: string; onSelect: (cmd: CommandItemType) => void; - hasFileResults: boolean; + taskResults: Task[]; + isSearching: boolean; + stepMap: Map; + repoMap: Map; + onTaskSelect: (task: Task) => void; }; function CommandsListContent({ @@ -105,13 +138,38 @@ function CommandsListContent({ grouped, search, onSelect, - hasFileResults, + taskResults, + isSearching, + stepMap, + repoMap, + onTaskSelect, }: CommandsListContentProps) { + const hasInlineResults = taskResults.length > 0 || isSearching; return ( <> - {!hasFileResults && No commands found.} + {!hasInlineResults && !isSearching && No commands found.} + {isSearching && taskResults.length === 0 && ( + +
+ +
+
+ )} + {taskResults.length > 0 && ( + + {taskResults.map((task) => ( + + ))} + + )} {search.trim() ? ( - + {commands.map((cmd) => ( ))} @@ -129,56 +187,23 @@ function CommandsListContent({ ); } -type SearchTasksContentProps = { +type FileSearchContentProps = { + files: string[]; isSearching: boolean; search: string; - taskResults: Task[]; - stepMap: Map; - onTaskSelect: (task: Task) => void; + onSelect: (path: string) => void; }; -function SearchTasksContent({ - isSearching, - search, - taskResults, - stepMap, - onTaskSelect, -}: SearchTasksContentProps) { - if (isSearching) +function FileSearchContent({ files, isSearching, search, onSelect }: FileSearchContentProps) { + if (isSearching && files.length === 0) { return (
); - if (search.trim() && taskResults.length === 0) - return No tasks found.; - if (!search.trim()) return Type to search tasks...; - return ( - - {taskResults.map((task) => ( - - ))} - - ); -} - -type FileSearchResultsProps = { - files: string[]; - isSearching: boolean; - onSelect: (path: string) => void; -}; - -function FileSearchResults({ files, isSearching, onSelect }: FileSearchResultsProps) { - if (isSearching && files.length === 0) { - return ( - -
- -
-
- ); } - if (files.length === 0) return null; + if (search.trim() && files.length === 0) return No files found.; + if (!search.trim()) return Type to search files...; return ( {files.map((filePath) => { @@ -204,31 +229,46 @@ function FileSearchResults({ files, isSearching, onSelect }: FileSearchResultsPr function getInputPlaceholder(mode: CommandPanelMode, inputCommand: CommandItemType | null) { if (mode === "input") return inputCommand?.inputPlaceholder ?? "Enter value..."; - if (mode === MODE_SEARCH_TASKS) return "Search for tasks..."; + if (mode === "search-tasks") return "Search for tasks..."; + if (mode === MODE_SEARCH_FILES) return "Search for files..."; return "Type a command..."; } function getEnterLabel(mode: CommandPanelMode) { if (mode === "input") return "Confirm"; - if (mode === MODE_SEARCH_TASKS) return "Open"; + if (mode === "search-tasks" || mode === MODE_SEARCH_FILES) return "Open"; return "Select"; } +function getModeLabel(mode: CommandPanelMode, inputCommand: CommandItemType | null) { + if (mode === "input") return inputCommand?.label; + if (mode === "search-tasks") return "Tasks"; + if (mode === MODE_SEARCH_FILES) return "Files"; + return null; +} + function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) { + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); return (
- {mode === "commands" && ( - - ↑ - ↓ - Navigate - + {mode === MODE_COMMANDS && ( + <> + + ↑ + ↓ + Navigate + + + {formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))} + File Search + + )} ↵ {getEnterLabel(mode)} - {mode !== "commands" && ( + {mode !== MODE_COMMANDS && ( ⌫ Back @@ -262,6 +302,7 @@ export type CommandPanelViewProps = { isSearching: boolean; taskResults: Task[]; stepMap: Map; + repoMap: Map; handleTaskSelect: (task: Task) => void; }; @@ -285,8 +326,10 @@ export function CommandPanelView({ isSearching, taskResults, stepMap, + repoMap, handleTaskSelect, }: CommandPanelViewProps) { + const modeLabel = getModeLabel(mode, inputCommand); return (
- {mode !== "commands" && ( + {mode !== MODE_COMMANDS && ( )} @@ -318,33 +362,27 @@ export function CommandPanelView({ />
- {mode === "commands" && ( - <> - {search.trim() && ( - - )} - 0 || isSearchingFiles} - /> - - )} - {mode === MODE_SEARCH_TASKS && ( - )} + {mode === MODE_SEARCH_FILES && ( + + )} {mode === "input" && (!search.trim() ? ( {inputCommand?.inputPlaceholder ?? "Enter a value..."} diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index ee58c696f2..4025e4ae52 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -16,8 +17,6 @@ import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { CommandPanelView } from "@/components/command-panel-footer"; -const MODE_SEARCH_TASKS: CommandPanelMode = "search-tasks"; - function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; } @@ -79,7 +78,7 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { fileDebounceRef, } = opts; useEffect(() => { - if (mode !== "commands" || !search.trim() || !activeSessionId) { + if (mode !== "search-files" || !search.trim() || !activeSessionId) { setFileResults([]); setIsSearchingFiles(false); return; @@ -121,11 +120,144 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { ]); } +const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); + +function resolveVisibleStepIds(steps: { id: string; show_in_command_panel?: boolean }[]) { + if (steps.length === 0) return null; // no steps loaded yet — don't filter + return new Set(steps.filter((s) => s.show_in_command_panel !== false).map((s) => s.id)); +} + +type InlineTaskSearchOptions = { + mode: CommandPanelMode; + search: string; + open: boolean; + workspaceId: string | null; + steps: { id: string; position: number; show_in_command_panel?: boolean }[]; + setTaskResults: (tasks: Task[]) => void; + setIsSearching: (searching: boolean) => void; +}; + +function useStepMaps(steps: InlineTaskSearchOptions["steps"]) { + const visibleStepIds = useMemo(() => resolveVisibleStepIds(steps), [steps]); + const stepPositionMap = useMemo(() => { + const map = new Map(); + for (const step of steps) map.set(step.id, step.position); + return map; + }, [steps]); + return { visibleStepIds, stepPositionMap }; +} + +function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { + const { mode, search, open, workspaceId, steps, setTaskResults, setIsSearching } = opts; + const debounceRef = useRef | null>(null); + const abortRef = useRef(null); + const { visibleStepIds, stepPositionMap } = useStepMaps(steps); + + useEffect(() => { + if (mode !== "commands") return; + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + + // No search: load active tasks (excluding backlog + done steps) + if (!search.trim()) { + if (!open || !workspaceId) { + setTaskResults([]); + setIsSearching(false); + return; + } + setIsSearching(true); + const controller = new AbortController(); + abortRef.current = controller; + listTasksByWorkspace( + workspaceId, + { page: 1, pageSize: 20 }, + { init: { signal: controller.signal } }, + ) + .then((res) => { + if (controller.signal.aborted) return; + const tasks = (res.tasks ?? []).filter( + (t) => + (!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && + !ARCHIVED_STATES.has(t.state), + ); + tasks.sort( + (a, b) => + (stepPositionMap.get(a.workflow_step_id) ?? 99) - + (stepPositionMap.get(b.workflow_step_id) ?? 99), + ); + setTaskResults(tasks); + }) + .catch(() => { + if (!controller.signal.aborted) setTaskResults([]); + }) + .finally(() => { + if (!controller.signal.aborted) setIsSearching(false); + }); + return () => { + controller.abort(); + }; + } + + // Search with < 2 chars: clear results + if (search.trim().length < 2) { + setTaskResults([]); + setIsSearching(false); + return; + } + + // Search: query API including archived + setIsSearching(true); + debounceRef.current = setTimeout(async () => { + if (!workspaceId) { + setIsSearching(false); + return; + } + const controller = new AbortController(); + abortRef.current = controller; + try { + const res = await listTasksByWorkspace( + workspaceId, + { query: search.trim(), page: 1, pageSize: 5, includeArchived: true }, + { init: { signal: controller.signal } }, + ); + if (!controller.signal.aborted) { + const tasks = res.tasks ?? []; + tasks.sort((a, b) => { + const aArchived = ARCHIVED_STATES.has(a.state) ? 1 : 0; + const bArchived = ARCHIVED_STATES.has(b.state) ? 1 : 0; + return aArchived - bArchived; + }); + setTaskResults(tasks); + } + } catch { + if (!controller.signal.aborted) setTaskResults([]); + } finally { + if (!controller.signal.aborted) setIsSearching(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current?.abort(); + }; + }, [ + mode, + search, + open, + workspaceId, + visibleStepIds, + stepPositionMap, + setTaskResults, + setIsSearching, + ]); +} + function useCommandPanelEffects( open: boolean, state: ReturnType, workspaceId: string | null, activeSessionId: string | null, + steps: { id: string; position: number; show_in_command_panel?: boolean }[], ) { const { mode, @@ -139,7 +271,7 @@ function useCommandPanelEffects( setIsSearchingFiles, setSelectedValue, } = state; - const { debounceRef, abortRef, fileDebounceRef } = useCommandPanelEffectRefs(); + const { fileDebounceRef } = useCommandPanelEffectRefs(); useEffect(() => { if (!open) { const t = setTimeout(() => { @@ -153,41 +285,17 @@ function useCommandPanelEffects( return () => clearTimeout(t); } }, [open, setMode, setSearch, setInputCommand, setTaskResults, setFileResults, setSelectedValue]); - useEffect(() => { - if (mode !== MODE_SEARCH_TASKS) return; - if (debounceRef.current) clearTimeout(debounceRef.current); - abortRef.current?.abort(); - if (!search.trim()) { - setTaskResults([]); - setIsSearching(false); - return; - } - setIsSearching(true); - debounceRef.current = setTimeout(async () => { - if (!workspaceId) { - setIsSearching(false); - return; - } - const controller = new AbortController(); - abortRef.current = controller; - try { - const res = await listTasksByWorkspace( - workspaceId, - { query: search.trim(), page: 1, pageSize: 10 }, - { init: { signal: controller.signal } }, - ); - if (!controller.signal.aborted) setTaskResults(res.tasks ?? []); - } catch { - if (!controller.signal.aborted) setTaskResults([]); - } finally { - if (!controller.signal.aborted) setIsSearching(false); - } - }, 300); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - abortRef.current?.abort(); - }; - }, [abortRef, debounceRef, mode, search, setIsSearching, setTaskResults, workspaceId]); + + useInlineTaskSearchEffect({ + mode, + search, + open, + workspaceId, + steps, + setTaskResults, + setIsSearching, + }); + useFileSearchEffect({ mode, search, @@ -204,6 +312,7 @@ function useCommandPanelHandlers( setOpen: (open: boolean) => void, commands: CommandItemType[], kanbanSteps: { id: string; title: string; color: string }[], + repositories: Array<{ id: string; local_path: string }>, ) { const { mode, search, inputCommand, setMode, setSearch, setInputCommand } = state; const router = useRouter(); @@ -228,6 +337,12 @@ function useCommandPanelHandlers( return map; }, [kanbanSteps]); + const repoMap = useMemo(() => { + const map = new Map(); + for (const repo of repositories) map.set(repo.id, repo.local_path); + return map; + }, [repositories]); + const handleSelect = useCallback( (cmd: CommandItemType) => { if (cmd.enterMode) { @@ -288,6 +403,7 @@ function useCommandPanelHandlers( return { grouped, stepMap, + repoMap, handleSelect, handleTaskSelect, handleFileSelect, @@ -302,6 +418,8 @@ export function CommandPanel() { const kanbanSteps = useAppStore((state) => state.kanban.steps); const workspaceId = useAppStore((state) => state.workspaces.activeId); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repositories = workspaceId ? (reposByWorkspace[workspaceId] ?? []) : []; const state = useCommandPanelState(); const { @@ -317,26 +435,44 @@ export function CommandPanel() { setSearch, } = state; - useCommandPanelEffects(open, state, workspaceId, activeSessionId); + useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps); const openRef = useRef(open); useEffect(() => { openRef.current = open; }, [open]); - const toggle = useCallback(() => setOpen(!openRef.current), [setOpen]); - useKeyboardShortcut(SHORTCUTS.SEARCH, toggle); - useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL, toggle); - useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggle); + + const toggleCommands = useCallback(() => setOpen(!openRef.current), [setOpen]); + + const openFileSearch = useCallback(() => { + if (openRef.current && state.mode === "search-files") { + setOpen(false); + } else { + state.setMode("search-files"); + state.setSearch(""); + setOpen(true); + } + }, [setOpen, state]); + + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const searchShortcut = getShortcut("SEARCH", keyboardShortcuts); + const fileSearchShortcut = getShortcut("FILE_SEARCH", keyboardShortcuts); + + useKeyboardShortcut(searchShortcut, toggleCommands); + useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL, toggleCommands); + useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggleCommands); + useKeyboardShortcut(fileSearchShortcut, openFileSearch); const { grouped, stepMap, + repoMap, handleSelect, handleTaskSelect, handleFileSelect, handleKeyDown, goBack, - } = useCommandPanelHandlers(state, setOpen, commands, kanbanSteps); + } = useCommandPanelHandlers(state, setOpen, commands, kanbanSteps, repositories); return ( ); diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx index 4b71506d09..4ec5bb7b01 100644 --- a/apps/web/components/global-commands.tsx +++ b/apps/web/components/global-commands.tsx @@ -4,7 +4,6 @@ import { useMemo } from "react"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { - IconSearch, IconHome, IconList, IconSettings, @@ -24,15 +23,6 @@ type PushFn = ReturnType["push"]; function buildNavigationCommands(push: PushFn): CommandItem[] { return [ - { - id: "search-tasks", - label: "Search Tasks", - group: "Search", - icon: , - keywords: ["find", "search", "task"], - enterMode: "search-tasks", - priority: 50, - }, { id: "nav-home", label: "Go to Home", diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 30df114120..8f5defb70b 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -237,6 +237,7 @@ function buildUserSettingsFromResponse( defaultEditorId: s.default_editor_id || null, enablePreviewOnClick: s.enable_preview_on_click ?? false, defaultUtilityAgentId: s.default_utility_agent_id || null, + keyboardShortcuts: s.keyboard_shortcuts ?? {}, ...mapEditorSettingsFields(s), }; } diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 01ee5a94de..5214cd2b83 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTheme } from "next-themes"; -import { IconPalette, IconServer, IconKeyboard } from "@tabler/icons-react"; +import { IconCommand, IconPalette, IconServer, IconKeyboard } from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Input } from "@kandev/ui/input"; @@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from "@kandev/ui/separator"; import { SettingsSection } from "@/components/settings/settings-section"; import { ShellSettingsCard } from "@/components/settings/shell-settings-card"; +import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card"; import { getBackendConfig } from "@/lib/config"; import { useAppStore } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; @@ -163,6 +164,16 @@ export function GeneralSettings() { + } + title="Keyboard Shortcuts" + description="Customize keyboard shortcuts for the command panel" + > + + + + + } title="Backend Connection" diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx new file mode 100644 index 0000000000..7b1c363be6 --- /dev/null +++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { IconRotate } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Kbd } from "@kandev/ui/kbd"; +import type { Key, KeyboardShortcut } from "@/lib/keyboard/constants"; +import { formatShortcut } from "@/lib/keyboard/utils"; +import { + CONFIGURABLE_SHORTCUTS, + resolveAllShortcuts, + type ConfigurableShortcutId, + type StoredShortcutOverrides, +} from "@/lib/keyboard/shortcut-overrides"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; + +function ShortcutRecorder({ + shortcutId, + current, + onChange, + onReset, +}: { + shortcutId: ConfigurableShortcutId; + current: KeyboardShortcut; + onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void; + onReset: (id: ConfigurableShortcutId) => void; +}) { + const [recording, setRecording] = useState(false); + const isDefault = + JSON.stringify(current) === JSON.stringify(CONFIGURABLE_SHORTCUTS[shortcutId].default); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!recording) return; + if (["Control", "Meta", "Alt", "Shift"].includes(e.key)) return; + + e.preventDefault(); + e.stopPropagation(); + + const newShortcut: KeyboardShortcut = { + key: (e.key.length === 1 ? e.key.toLowerCase() : e.key) as Key, + modifiers: { + ...(e.ctrlKey || e.metaKey ? { ctrlOrCmd: true } : {}), + ...(e.shiftKey ? { shift: true } : {}), + ...(e.altKey ? { alt: true } : {}), + }, + }; + + if (Object.keys(newShortcut.modifiers!).length === 0) { + delete newShortcut.modifiers; + } + + onChange(shortcutId, newShortcut); + setRecording(false); + }, + [recording, shortcutId, onChange], + ); + + useEffect(() => { + if (!recording) return; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [recording, handleKeyDown]); + + useEffect(() => { + if (!recording) return; + const handleBlur = () => setRecording(false); + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, [recording]); + + return ( +
+ {CONFIGURABLE_SHORTCUTS[shortcutId].label} +
+ + {!isDefault && ( + + )} +
+
+ ); +} + +export function KeyboardShortcutsCard() { + const storeOverrides = useAppStore((s) => s.userSettings.keyboardShortcuts); + const setUserSettings = useAppStore((s) => s.setUserSettings); + const userSettings = useAppStore((s) => s.userSettings); + const shortcuts = resolveAllShortcuts(storeOverrides); + const { toast } = useToast(); + + const persistOverrides = useCallback( + (overrides: StoredShortcutOverrides) => { + const previous = userSettings.keyboardShortcuts; + setUserSettings({ ...userSettings, keyboardShortcuts: overrides }); + updateUserSettings({ keyboard_shortcuts: overrides }).catch(() => { + setUserSettings({ ...userSettings, keyboardShortcuts: previous }); + toast({ title: "Failed to save shortcut", variant: "error" }); + }); + }, + [userSettings, setUserSettings, toast], + ); + + const handleChange = useCallback( + (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => { + const next = { ...storeOverrides, [id]: shortcut }; + persistOverrides(next); + }, + [storeOverrides, persistOverrides], + ); + + const handleReset = useCallback( + (id: ConfigurableShortcutId) => { + const next = { ...storeOverrides }; + delete next[id]; + persistOverrides(next); + }, + [storeOverrides, persistOverrides], + ); + + const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[]; + + return ( + + + Keyboard Shortcuts + + +
+ {ids.map((id) => ( + + ))} +
+

+ Click a shortcut to record a new key combination. Changes are saved automatically. +

+
+
+ ); +} diff --git a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx index fb7e9c334d..320ecf7ccb 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-panels.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-panels.tsx @@ -108,7 +108,7 @@ type StepAutoArchiveRowProps = { function StepAutoArchiveRow({ step, onUpdate, readOnly }: StepAutoArchiveRowProps) { return ( -
+
0} @@ -145,6 +145,41 @@ function StepAutoArchiveRow({ step, onUpdate, readOnly }: StepAutoArchiveRowProp ); } +// --- StepCheckboxRow --- + +type StepCheckboxRowProps = { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled: boolean; + label: string; + helpText: string; +}; + +function StepCheckboxRow({ + id, + checked, + onCheckedChange, + disabled, + label, + helpText, +}: StepCheckboxRowProps) { + return ( +
+ onCheckedChange(v === true)} + disabled={disabled} + /> + + +
+ ); +} + // --- StepBehaviorSection --- type StepBehaviorSectionProps = { @@ -163,83 +198,56 @@ function StepBehaviorSection({ return (
-
- { - if (readOnly) return; - onUpdate({ is_start_step: checked === true }); - }} - disabled={readOnly} - /> - - -
-
- { - if (readOnly) return; - toggleOnEnterAction("auto_start_agent"); - }} - disabled={readOnly} - /> - - -
-
- { - if (readOnly) return; - toggleOnEnterAction("enable_plan_mode"); - }} - disabled={readOnly} - /> - - -
-
- { - if (readOnly) return; - toggleOnEnterAction("reset_agent_context"); - }} - disabled={readOnly} - /> - - -
-
- { - if (readOnly) return; - onUpdate({ allow_manual_move: checked === true }); - }} - disabled={readOnly} - /> - - -
+ !readOnly && onUpdate({ is_start_step: c })} + disabled={readOnly} + label="Start step" + helpText="New tasks start in this step. Only one step per workflow can be the start step." + /> + !readOnly && toggleOnEnterAction("auto_start_agent")} + disabled={readOnly} + label="Auto-start agent" + helpText="Automatically start the agent when a task enters this step." + /> + !readOnly && toggleOnEnterAction("enable_plan_mode")} + disabled={readOnly} + label="Plan mode" + helpText="Agent proposes a plan instead of making changes directly." + /> + !readOnly && toggleOnEnterAction("reset_agent_context")} + disabled={readOnly} + label="Reset agent context" + helpText="Restart the agent with a fresh conversation context when entering this step. Useful for review steps that need an unbiased perspective." + /> + !readOnly && onUpdate({ allow_manual_move: c })} + disabled={readOnly} + label="Allow manual move" + helpText="Allow dragging tasks into this step on the board." + /> + !readOnly && onUpdate({ show_in_command_panel: c })} + disabled={readOnly} + label="Show in command panel" + helpText="Show tasks in this step when opening the command panel (Cmd+K). Useful for hiding backlog or done steps from quick access." + /> +
-
); } diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index c74c5633e2..0962d0d20c 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -89,6 +89,7 @@ export const test = backendFixture.extend< await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, + keyboard_shortcuts: {}, }); const context = await browser.newContext({ baseURL: backend.frontendUrl, diff --git a/apps/web/e2e/tests/command-panel.spec.ts b/apps/web/e2e/tests/command-panel.spec.ts new file mode 100644 index 0000000000..a4577c4026 --- /dev/null +++ b/apps/web/e2e/tests/command-panel.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from "../fixtures/test-base"; +import { KanbanPage } from "../pages/kanban-page"; + +/** + * Helper: open the command panel via Cmd/Ctrl+K. + */ +async function openCommandPanel(page: import("@playwright/test").Page) { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+k`); +} + +/** + * Helper: open the file search panel via Cmd/Ctrl+Shift+K. + */ +async function openFileSearch(page: import("@playwright/test").Page) { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+Shift+k`); +} + +/** The command dialog (Radix Dialog with role="dialog"). */ +function commandDialog(page: import("@playwright/test").Page) { + return page.getByRole("dialog"); +} + +test.describe("Command Panel", () => { + test("Cmd+K opens command panel and shows commands (not files)", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Should show command groups like Navigation, Settings + await expect(dialog.getByText("Navigation")).toBeVisible(); + await expect(dialog.getByText("Go to Home")).toBeVisible(); + + // Type something that matches a navigation command + await dialog.locator("input").fill("Settings"); + // Should find settings commands + await expect(dialog.getByText("Go to Settings")).toBeVisible({ timeout: 5_000 }); + + // Should NOT show a "Files" group — file search is now separate + await expect(dialog.getByText("Files").first()).not.toBeVisible({ timeout: 2_000 }); + }); + + test("Cmd+Shift+K opens file search mode with appropriate placeholder", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await openFileSearch(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Should show the "Files" back-button breadcrumb + await expect(dialog.getByRole("button", { name: /Files/ })).toBeVisible(); + + // Should show empty state for file search + await expect(dialog.getByText("Type to search files...")).toBeVisible(); + }); + + test("Cmd+K inline task search shows matching tasks", async ({ + testPage, + apiClient, + seedData, + }) => { + // Create a task to search for + await apiClient.createTask(seedData.workspaceId, "Searchable E2E Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Wait for the task to appear on the kanban board first + await expect(kanban.taskCardByTitle("Searchable E2E Task")).toBeVisible({ timeout: 10_000 }); + + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Type the task name — task search requires ≥2 characters + await dialog.locator("input").fill("Searchable"); + + // Should show the task in a "Tasks" group (inline search, debounced) + await expect(dialog.getByText("Tasks")).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText("Searchable E2E Task")).toBeVisible({ timeout: 5_000 }); + }); + + test("inline task search shows workflow step badge", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Badged Task E2E", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await expect(kanban.taskCardByTitle("Badged Task E2E")).toBeVisible({ timeout: 10_000 }); + + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + await dialog.locator("input").fill("Badged Task"); + + // The task should appear with a workflow step badge + const taskRow = dialog.getByText("Badged Task E2E"); + await expect(taskRow).toBeVisible({ timeout: 10_000 }); + + // The step badge should be present in the task result row + const startStep = seedData.steps.find((s) => s.id === seedData.startStepId)!; + const taskOption = dialog.getByRole("option", { name: /Badged Task E2E/ }); + await expect(taskOption).toBeVisible({ timeout: 5_000 }); + await expect(taskOption.getByText(startStep.name)).toBeVisible({ timeout: 5_000 }); + }); + + test("Escape closes the command panel", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + await testPage.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + }); + + test("Backspace in file search mode returns to commands mode", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Open file search mode + await openFileSearch(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog.getByText("Type to search files...")).toBeVisible(); + + // Focus the input and press backspace on empty input to go back to commands mode + const input = dialog.getByRole("combobox"); + await input.focus(); + await input.press("Backspace"); + + // Should now show the commands mode (navigation commands visible) + await expect(dialog.getByText("Navigation")).toBeVisible({ timeout: 5_000 }); + await expect(dialog.getByText("Go to Home")).toBeVisible({ timeout: 3_000 }); + }); + + test("Cmd+K toggles the panel open and closed", async ({ testPage }) => { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + // Open + await openCommandPanel(testPage); + const dialog = commandDialog(testPage); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Close by pressing Cmd+K again + await openCommandPanel(testPage); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 80aa5dc98a..d7d39e22c6 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -39,6 +39,7 @@ function carryForwardSettings(current: DisplaySettings) { lspServerConfigs: current.lspServerConfigs ?? {}, savedLayouts: current.savedLayouts ?? [], defaultUtilityAgentId: current.defaultUtilityAgentId ?? null, + keyboardShortcuts: current.keyboardShortcuts ?? {}, }; } diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 2abb2fd050..1879249ae5 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -45,6 +45,7 @@ export async function updateUserSettings( saved_layouts?: SavedLayout[]; default_utility_agent_id?: string; default_utility_model?: string; + keyboard_shortcuts?: Record }>; }, options?: ApiRequestOptions, ) { diff --git a/apps/web/lib/api/domains/workflow-api.ts b/apps/web/lib/api/domains/workflow-api.ts index a42f3dd32e..5f7b15d31d 100644 --- a/apps/web/lib/api/domains/workflow-api.ts +++ b/apps/web/lib/api/domains/workflow-api.ts @@ -15,6 +15,8 @@ type BackendTemplateStep = { color?: string; prompt?: string; events?: StepEvents; + is_start_step?: boolean; + show_in_command_panel?: boolean; }; type BackendWorkflowTemplate = Omit & { @@ -30,6 +32,8 @@ const normalizeWorkflowTemplate = (template: BackendWorkflowTemplate): WorkflowT color: step.color, prompt: step.prompt, events: step.events, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, })); return { ...template, diff --git a/apps/web/lib/commands/types.ts b/apps/web/lib/commands/types.ts index 69c556b89f..224b9ed41b 100644 --- a/apps/web/lib/commands/types.ts +++ b/apps/web/lib/commands/types.ts @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import type { KeyboardShortcut } from "@/lib/keyboard/constants"; -export type CommandPanelMode = "commands" | "search-tasks" | "input"; +export type CommandPanelMode = "commands" | "search-tasks" | "search-files" | "input"; export type CommandItem = { id: string; diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts index f7a41a9834..35eb0bebab 100644 --- a/apps/web/lib/keyboard/constants.ts +++ b/apps/web/lib/keyboard/constants.ts @@ -129,6 +129,10 @@ export const SHORTCUTS = { key: KEYS.P, modifiers: { ctrlOrCmd: true, shift: true }, }, + FILE_SEARCH: { + key: KEYS.K, + modifiers: { ctrlOrCmd: true, shift: true }, + }, TOGGLE_PLAN_MODE: { key: KEYS.TAB, modifiers: { shift: true }, diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts new file mode 100644 index 0000000000..958a168fa9 --- /dev/null +++ b/apps/web/lib/keyboard/shortcut-overrides.ts @@ -0,0 +1,34 @@ +import { SHORTCUTS, type KeyboardShortcut } from "./constants"; + +export type ConfigurableShortcutId = "SEARCH" | "FILE_SEARCH"; + +export type StoredShortcutOverrides = Record< + string, + { key: string; modifiers?: Record } +>; + +export const CONFIGURABLE_SHORTCUTS: Record< + ConfigurableShortcutId, + { label: string; default: KeyboardShortcut } +> = { + SEARCH: { label: "Command Panel", default: SHORTCUTS.SEARCH }, + FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH }, +}; + +export function getShortcut( + id: ConfigurableShortcutId, + overrides?: StoredShortcutOverrides, +): KeyboardShortcut { + const override = overrides?.[id]; + if (override) return override as KeyboardShortcut; + return CONFIGURABLE_SHORTCUTS[id].default; +} + +export function resolveAllShortcuts( + overrides?: StoredShortcutOverrides, +): Record { + return { + SEARCH: getShortcut("SEARCH", overrides), + FILE_SEARCH: getShortcut("FILE_SEARCH", overrides), + }; +} diff --git a/apps/web/lib/keyboard/utils.ts b/apps/web/lib/keyboard/utils.ts index baff9f38da..69f1960d4e 100644 --- a/apps/web/lib/keyboard/utils.ts +++ b/apps/web/lib/keyboard/utils.ts @@ -47,11 +47,10 @@ function collectModifierNames( if (modifiers.ctrlOrCmd) { parts.push(currentPlatform === "mac" ? "Cmd" : "Ctrl"); - return parts; + } else { + if (modifiers.ctrl) parts.push("Ctrl"); + if (modifiers.cmd && currentPlatform === "mac") parts.push("Cmd"); } - - if (modifiers.ctrl) parts.push("Ctrl"); - if (modifiers.cmd && currentPlatform === "mac") parts.push("Cmd"); if (modifiers.alt) parts.push(currentPlatform === "mac" ? "Option" : "Alt"); if (modifiers.shift) parts.push("Shift"); return parts; @@ -148,9 +147,9 @@ export function shortcutToCodeMirrorKeybinding(shortcut: KeyboardShortcut): stri } else { if (shortcut.modifiers.ctrl) parts.push("Ctrl"); if (shortcut.modifiers.cmd) parts.push("Mod"); - if (shortcut.modifiers.alt) parts.push("Alt"); - if (shortcut.modifiers.shift) parts.push("Shift"); } + if (shortcut.modifiers.alt) parts.push("Alt"); + if (shortcut.modifiers.shift) parts.push("Shift"); } parts.push(shortcut.key); diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 5a9286ec59..98527a593f 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -38,6 +38,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { allow_manual_move: step.allow_manual_move, prompt: step.prompt, is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, })), tasks, }, diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 0f7a29035c..fcc66ada39 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -17,6 +17,7 @@ export function buildCoreFields(s: UserSettingsData) { releaseNotesLastSeenVersion: s.release_notes_last_seen_version || null, savedLayouts: s.saved_layouts ?? [], defaultUtilityAgentId: s.default_utility_agent_id || null, + keyboardShortcuts: s.keyboard_shortcuts ?? {}, }; } @@ -51,6 +52,7 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) { releaseNotesLastSeenVersion: null, savedLayouts: [] as SavedLayout[], defaultUtilityAgentId: null, + keyboardShortcuts: {} as Record }>, ...buildLspFields(undefined), loaded: false, }; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index ef817fa1cf..81c4f99219 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -18,6 +18,7 @@ export type KanbanState = { allow_manual_move?: boolean; prompt?: string; is_start_step?: boolean; + show_in_command_panel?: boolean; }>; tasks: Array<{ id: string; diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 1d6c6d095e..4408e94e6d 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -37,6 +37,7 @@ export const defaultSettingsState: SettingsSliceState = { lspServerConfigs: {}, savedLayouts: [], defaultUtilityAgentId: null, + keyboardShortcuts: {}, loaded: false, }, }; diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 61d3f6a709..c7d80691f7 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -115,6 +115,7 @@ export type UserSettingsState = { lspServerConfigs: Record>; savedLayouts: SavedLayout[]; defaultUtilityAgentId: string | null; + keyboardShortcuts: Record }>; loaded: boolean; }; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index a078f9fe95..bc88136e25 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -82,6 +82,7 @@ export type KanbanUpdatePayload = { on_enter?: Array<{ type: string; config?: Record }>; on_turn_complete?: Array<{ type: string; config?: Record }>; }; + show_in_command_panel?: boolean; }>; tasks: Array<{ id: string; @@ -364,6 +365,7 @@ export type UserSettingsUpdatedPayload = { lsp_auto_install_languages?: string[]; saved_layouts?: SavedLayout[]; default_utility_agent_id?: string; + keyboard_shortcuts?: Record }>; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index a17e32c17e..543dbca1f2 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -75,6 +75,7 @@ export type StepDefinition = { prompt?: string; events?: StepEvents; is_start_step?: boolean; + show_in_command_panel?: boolean; }; // Workflow Step - instance of a step on a workflow @@ -88,6 +89,7 @@ export type WorkflowStep = { events?: StepEvents; allow_manual_move?: boolean; is_start_step?: boolean; + show_in_command_panel?: boolean; auto_archive_after_hours?: number; created_at: string; updated_at: string; @@ -233,6 +235,8 @@ export type Task = { primary_executor_id?: string | null; primary_executor_type?: string | null; primary_executor_name?: string | null; + primary_agent_name?: string | null; + primary_working_directory?: string | null; is_remote_executor?: boolean; archived_at?: string | null; created_at: string; @@ -256,6 +260,7 @@ export type WorkflowStepDTO = { events?: StepEvents; allow_manual_move: boolean; is_start_step?: boolean; + show_in_command_panel?: boolean; auto_archive_after_hours?: number; created_at?: string; updated_at?: string; @@ -367,6 +372,7 @@ export type UserSettings = { saved_layouts?: SavedLayout[]; default_utility_agent_id?: string; default_utility_model?: string; + keyboard_shortcuts?: Record }>; updated_at: string; }; diff --git a/apps/web/lib/ws/handlers/kanban.ts b/apps/web/lib/ws/handlers/kanban.ts index 456318306b..0b93d581ac 100644 --- a/apps/web/lib/ws/handlers/kanban.ts +++ b/apps/web/lib/ws/handlers/kanban.ts @@ -17,6 +17,7 @@ export function registerKanbanHandlers(store: StoreApi): WsHandlers { color: step.color ?? "bg-neutral-400", position: step.position ?? index, events: step.events, + show_in_command_panel: step.show_in_command_panel, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const tasks: KanbanTask[] = message.payload.tasks.map((task: any) => ({ diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index 15e5a3b8af..04deef5d7d 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -27,6 +27,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers { lspAutoInstallLanguages: message.payload.lsp_auto_install_languages ?? [], savedLayouts: message.payload.saved_layouts ?? [], defaultUtilityAgentId: message.payload.default_utility_agent_id || null, + keyboardShortcuts: message.payload.keyboard_shortcuts ?? {}, loaded: true, }, }));