feat: split file search from command panel, add inline task search & configurable shortcuts (#383)

* feat(web): split file search from command panel and add inline task search

Separate the command panel (Cmd+K) from file search (Cmd+Shift+K) to
prevent file results from overpowering commands. Add inline task search
directly in the command panel — tasks appear in a "Tasks" group when
typing ≥2 characters, with workflow step badges and archived state
indicators. Archived tasks are sorted last with reduced opacity.

- Add "search-files" mode to CommandPanelMode
- Add FILE_SEARCH shortcut (Cmd/Ctrl+Shift+K)
- Move file search to dedicated mode, remove from commands mode
- Add useInlineTaskSearchEffect for commands mode task search
- Remove "Search Tasks" sub-mode entry point (now inline)
- Show file search shortcut hint in command panel footer

* feat(web): add configurable keyboard shortcuts in general settings

Add localStorage-based shortcut override system for the command panel
and file search keybindings. Users can record new key combos via the
settings UI and reset to defaults.

- Add shortcut-overrides module (get/set/reset via localStorage)
- Add KeyboardShortcutsCard with shortcut recorder UI
- Add "Keyboard Shortcuts" section to general settings page

* test(web): add e2e tests for command panel and file search split

Cover the key use cases: Cmd+K opens commands without files,
Cmd+Shift+K opens file search, inline task search with workflow step
badges, escape/backspace navigation, and panel toggle behavior.

* fix(test): remove unused seedData variable in command panel e2e test

* feat(settings): persist keyboard shortcuts to user settings API

Replace localStorage-based shortcut overrides with server-persisted
storage via the user settings API. Adds keyboard_shortcuts field to
the backend model, DTO, store, service, and controller. Frontend
reads overrides from Zustand store (hydrated from SSR/WS) and saves
via PATCH /api/v1/user/settings.

* fix(ui): prioritize tasks over commands, add step colors, fix shortcut display

- Move task results above command results in cmd+K for faster task switching
- Replace magnifier icon with task icon (IconSubtask) for task results
- Apply workflow step color to task badges
- Fix formatShortcut early return bug that dropped Shift modifier
  when ctrlOrCmd was set (e.g. Cmd+Shift+K showed as Cmd+K)
- Fix same bug in shortcutToCodeMirrorKeybinding

* fix(ui): use clipboard icon for task results in command panel

* style: format command panel footer

* fix(ui): use hammer icon and map step colors to hex for task badges

* feat(ui): show repo directory and agent name on task results

Display secondary details (repository directory, agent name, last
updated) on the right side of task results in the command panel.

* fix: avoid inline selector closure to prevent getServerSnapshot loop

Select the raw repositories map from the store instead of using an
inline selector that closes over workspaceId and returns new array
references, which triggers React's getServerSnapshot infinite loop.

* feat(task): expose agent name and working directory in task DTO

Add primary_agent_name (from AgentProfileSnapshot) and
primary_working_directory (from RepositorySnapshot) to TaskDTO.
Extend the primary session info query to fetch agent_profile_snapshot
and repository_snapshot columns, and extract the new fields in the
handler layer.

* feat(ui): show agent name and working directory on command panel tasks

Replace executor name with agent name from the primary session and
display the working directory (with repository path fallback) in the
task result details.

* fix(ui): add Commands heading to separate tasks from commands in search

* fix(task): use profile display name instead of agent slug

* feat(workflow): add show_in_command_panel per-step configuration

Add a configurable boolean to each workflow step that controls whether
tasks in that step appear in the command panel (Cmd+K). Default
templates mark backlog/done steps as hidden, active steps as visible.

- Add show_in_command_panel to WorkflowStep, StepDefinition, StepPortable
- Add column with idempotent migration (DEFAULT 1 for backwards compat)
- Include field in DTO serialization and export/import round-trip
- Update all default templates (Plan & Build, Architecture, Kanban, PR Review)
- Add tests for export preservation and import round-trip

* feat(ui): wire show_in_command_panel through frontend and settings

- Add field to HTTP types (WorkflowStep, WorkflowStepDTO, StepDefinition)
- Add to kanban state types and KanbanUpdatePayload
- Map in SSR snapshot mapper and WS kanban handler
- Add "Show in command panel" checkbox to workflow step settings
- Extract StepCheckboxRow component to stay within lint limits

* feat(ui): filter command panel tasks by show_in_command_panel flag

Replace position-based heuristic (resolveExcludedStepIds) with the
per-step show_in_command_panel configuration. Tasks in steps where
the flag is false are hidden from the default Cmd+K view but still
accessible via search.

- Replace resolveExcludedStepIds with resolveVisibleStepIds
- Consolidate useInlineTaskSearchEffect params into options object

* fix(ui): differentiate task sections in command panel

Rename the inline task results heading to "Active Tasks" when showing
the default view, distinguishing it from the "Tasks" command group
that contains "Create New Task".

* fix(ui): keep auto-archive checkbox inline with other step options

* fix(ui): include show_in_command_panel in step update action

The server action was missing show_in_command_panel from its Pick type
and body builder, so the checkbox change was silently dropped.

* fix(ui): pass is_start_step and show_in_command_panel from template steps

Both normalizeWorkflowTemplate mappers (workflow-api.ts and
workspaces.ts) were only picking name/position/color/prompt/events
from backend template steps, dropping is_start_step and
show_in_command_panel. This caused all template steps to appear
with show_in_command_panel checked when creating a new workflow.

* fix(ui): fix command panel empty tasks and step update losing checkbox state

Two fixes:
- Command panel: when kanban steps haven't loaded yet, don't filter
  tasks by visibility (resolveVisibleStepIds returns null for empty
  steps instead of an empty Set that rejects everything)
- Workflow settings: BackendWorkflowStep type and transformWorkflowStep
  were missing show_in_command_panel, so refreshing steps after an
  update dropped the field and the checkbox always reverted

* fix(ui): prevent back button from stealing focus in command panel

The back button was focusable and appeared before CommandInput, so
opening file search mode focused the button instead of the input.
Add tabIndex={-1} to keep focus on the search input.

* feat(web): show archive icon for archived tasks in command panel

Replace the text badge (e.g. "COMPLETED") with an IconArchive for
archived tasks in the command panel search results. Active tasks
keep the existing IconHammer icon.

* style: fix Prettier formatting and extract useStepMaps hook

Fix Prettier formatting issues in command-panel.tsx and
workflow-pipeline-editor-panels.tsx. Extract useStepMaps hook
from useInlineTaskSearchEffect to stay within the 100-line
function limit.

* fix: address PR review feedback

- Validate keyboard shortcut payload shape before persisting (service.go)
- Normalize KeyboardShortcuts to {} on read path for legacy rows (sqlite.go)
- Deduplicate getRepoDir into getFileName (command-panel-footer.tsx)
- Show toast on shortcut persistence failure instead of swallowing (keyboard-shortcuts-card.tsx)
- Reset keyboard_shortcuts in E2E test fixture for deterministic tests (test-base.ts)
This commit is contained in:
José Almeida
2026-03-07 03:59:01 +00:00
committed by GitHub
parent 21cb909ef4
commit 74a8d7563e
43 changed files with 1106 additions and 347 deletions
@@ -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 {
+53 -45
View File
@@ -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"`
@@ -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
}
@@ -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()
@@ -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
+3
View File
@@ -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),
}
}
@@ -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"`
}
@@ -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 {
@@ -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
}
@@ -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
}
@@ -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,
})
@@ -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))
@@ -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"`
@@ -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 = ?
@@ -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,
}
@@ -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()
+9
View File
@@ -272,6 +272,8 @@ type BackendTemplateStep = {
color?: string;
prompt?: string;
events?: StepEvents;
is_start_step?: boolean;
show_in_command_panel?: boolean;
};
type BackendWorkflowTemplate = Omit<WorkflowTemplate, "default_steps"> & {
@@ -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<BackendWorkflowStep>(
@@ -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: "",
+138 -100
View File
@@ -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<string, string> = {
"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<string, { name: string; color: string }>;
repoMap: Map<string, string>;
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 (
<CommandItem
key={task.id}
value={task.id}
value={`__task:${task.id} ${task.title}`}
onSelect={() => onSelect(task)}
className={isArchived ? "opacity-60" : ""}
forceMount
>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<IconSearch className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{task.title}</span>
{step && (
<Badge variant="secondary" className="text-[0.6rem] shrink-0">
{step.name}
</Badge>
)}
{isArchived && (
<Badge variant="outline" className="text-[0.6rem] shrink-0 opacity-70">
{task.state}
</Badge>
)}
</div>
{task.updated_at && (
<div className="flex items-center gap-1.5 text-[0.6rem] text-muted-foreground pl-5">
<span>{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}</span>
</div>
<div className="flex items-center gap-2 min-w-0 w-full">
{isArchived ? (
<IconArchive className="size-3 shrink-0 text-muted-foreground" />
) : (
<IconHammer className="size-3 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium">{task.title}</span>
{step && (
<Badge
variant="secondary"
className="text-[0.6rem] shrink-0"
style={stepHex ? { backgroundColor: stepHex + "22", color: stepHex } : undefined}
>
{step.name}
</Badge>
)}
{details.length > 0 && (
<span className="ml-auto text-[0.6rem] text-muted-foreground truncate shrink-0">
{details.join(" · ")}
</span>
)}
</div>
</CommandItem>
@@ -97,7 +126,11 @@ type CommandsListContentProps = {
grouped: [string, CommandItemType[]][];
search: string;
onSelect: (cmd: CommandItemType) => void;
hasFileResults: boolean;
taskResults: Task[];
isSearching: boolean;
stepMap: Map<string, { name: string; color: string }>;
repoMap: Map<string, string>;
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 && <CommandEmpty>No commands found.</CommandEmpty>}
{!hasInlineResults && !isSearching && <CommandEmpty>No commands found.</CommandEmpty>}
{isSearching && taskResults.length === 0 && (
<CommandGroup heading="Active Tasks" forceMount>
<div className="flex items-center justify-center py-3">
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
</div>
</CommandGroup>
)}
{taskResults.length > 0 && (
<CommandGroup heading={search.trim() ? "Tasks" : "Active Tasks"} forceMount>
{taskResults.map((task) => (
<TaskResultItem
key={task.id}
task={task}
stepMap={stepMap}
repoMap={repoMap}
onSelect={onTaskSelect}
/>
))}
</CommandGroup>
)}
{search.trim() ? (
<CommandGroup>
<CommandGroup heading="Commands">
{commands.map((cmd) => (
<CommandItemRow key={cmd.id} cmd={cmd} onSelect={onSelect} />
))}
@@ -129,56 +187,23 @@ function CommandsListContent({
);
}
type SearchTasksContentProps = {
type FileSearchContentProps = {
files: string[];
isSearching: boolean;
search: string;
taskResults: Task[];
stepMap: Map<string, { name: string; color: string }>;
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 (
<div className="flex items-center justify-center py-6">
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
if (search.trim() && taskResults.length === 0)
return <CommandEmpty>No tasks found.</CommandEmpty>;
if (!search.trim()) return <CommandEmpty>Type to search tasks...</CommandEmpty>;
return (
<CommandGroup heading="Results">
{taskResults.map((task) => (
<TaskResultItem key={task.id} task={task} stepMap={stepMap} onSelect={onTaskSelect} />
))}
</CommandGroup>
);
}
type FileSearchResultsProps = {
files: string[];
isSearching: boolean;
onSelect: (path: string) => void;
};
function FileSearchResults({ files, isSearching, onSelect }: FileSearchResultsProps) {
if (isSearching && files.length === 0) {
return (
<CommandGroup heading="Files" forceMount>
<div className="flex items-center justify-center py-3">
<IconLoader2 className="size-3.5 animate-spin text-muted-foreground" />
</div>
</CommandGroup>
);
}
if (files.length === 0) return null;
if (search.trim() && files.length === 0) return <CommandEmpty>No files found.</CommandEmpty>;
if (!search.trim()) return <CommandEmpty>Type to search files...</CommandEmpty>;
return (
<CommandGroup heading="Files" forceMount>
{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 (
<div className="border-t border-border px-3 py-1.5 flex items-center gap-3 text-[0.6rem] text-muted-foreground">
{mode === "commands" && (
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>Navigate</span>
</KbdGroup>
{mode === MODE_COMMANDS && (
<>
<KbdGroup>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span>Navigate</span>
</KbdGroup>
<KbdGroup>
<Kbd>{formatShortcut(getShortcut("FILE_SEARCH", keyboardShortcuts))}</Kbd>
<span>File Search</span>
</KbdGroup>
</>
)}
<KbdGroup>
<Kbd>↵</Kbd>
<span>{getEnterLabel(mode)}</span>
</KbdGroup>
{mode !== "commands" && (
{mode !== MODE_COMMANDS && (
<KbdGroup>
<Kbd>⌫</Kbd>
<span>Back</span>
@@ -262,6 +302,7 @@ export type CommandPanelViewProps = {
isSearching: boolean;
taskResults: Task[];
stepMap: Map<string, { name: string; color: string }>;
repoMap: Map<string, string>;
handleTaskSelect: (task: Task) => void;
};
@@ -285,8 +326,10 @@ export function CommandPanelView({
isSearching,
taskResults,
stepMap,
repoMap,
handleTaskSelect,
}: CommandPanelViewProps) {
const modeLabel = getModeLabel(mode, inputCommand);
return (
<CommandDialog
open={open}
@@ -294,19 +337,20 @@ export function CommandPanelView({
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
>
<Command
shouldFilter={mode === "commands"}
shouldFilter={mode === MODE_COMMANDS}
loop
value={selectedValue}
onValueChange={setSelectedValue}
>
<div className="flex items-center border-b border-border [&>[data-slot=command-input-wrapper]]:flex-1">
{mode !== "commands" && (
{mode !== MODE_COMMANDS && (
<button
onClick={goBack}
tabIndex={-1}
className="shrink-0 pl-2 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<span>←</span>
<span>{mode === "input" ? inputCommand?.label : "Tasks"}</span>
<span>{modeLabel}</span>
<span className="text-muted-foreground/50">›</span>
</button>
)}
@@ -318,33 +362,27 @@ export function CommandPanelView({
/>
</div>
<CommandList>
{mode === "commands" && (
<>
{search.trim() && (
<FileSearchResults
files={fileResults}
isSearching={isSearchingFiles}
onSelect={handleFileSelect}
/>
)}
<CommandsListContent
commands={commands}
grouped={grouped}
search={search}
onSelect={handleSelect}
hasFileResults={fileResults.length > 0 || isSearchingFiles}
/>
</>
)}
{mode === MODE_SEARCH_TASKS && (
<SearchTasksContent
isSearching={isSearching}
{mode === MODE_COMMANDS && (
<CommandsListContent
commands={commands}
grouped={grouped}
search={search}
onSelect={handleSelect}
taskResults={taskResults}
isSearching={isSearching}
stepMap={stepMap}
repoMap={repoMap}
onTaskSelect={handleTaskSelect}
/>
)}
{mode === MODE_SEARCH_FILES && (
<FileSearchContent
files={fileResults}
isSearching={isSearchingFiles}
search={search}
onSelect={handleFileSelect}
/>
)}
{mode === "input" &&
(!search.trim() ? (
<CommandEmpty>{inputCommand?.inputPlaceholder ?? "Enter a value..."}</CommandEmpty>
+182 -45
View File
@@ -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<string, number>();
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<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(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<typeof useCommandPanelState>,
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<string, string>();
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 (
<CommandPanelView
@@ -359,6 +495,7 @@ export function CommandPanel() {
isSearching={isSearching}
taskResults={taskResults}
stepMap={stepMap}
repoMap={repoMap}
handleTaskSelect={handleTaskSelect}
/>
);
-10
View File
@@ -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<typeof useRouter>["push"];
function buildNavigationCommands(push: PushFn): CommandItem[] {
return [
{
id: "search-tasks",
label: "Search Tasks",
group: "Search",
icon: <IconSearch className="size-3.5" />,
keywords: ["find", "search", "task"],
enterMode: "search-tasks",
priority: 50,
},
{
id: "nav-home",
label: "Go to Home",
@@ -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),
};
}
@@ -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() {
<Separator />
<SettingsSection
icon={<IconCommand className="h-5 w-5" />}
title="Keyboard Shortcuts"
description="Customize keyboard shortcuts for the command panel"
>
<KeyboardShortcutsCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconServer className="h-5 w-5" />}
title="Backend Connection"
@@ -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 (
<div className="flex items-center justify-between py-2">
<span className="text-sm">{CONFIGURABLE_SHORTCUTS[shortcutId].label}</span>
<div className="flex items-center gap-2">
<button
data-testid={`shortcut-recorder-${shortcutId}`}
onClick={() => setRecording(!recording)}
className={`px-3 py-1.5 rounded-md border text-sm cursor-pointer transition-colors ${
recording
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background hover:bg-accent"
}`}
>
{recording ? (
<span className="animate-pulse">Press a key combo...</span>
) : (
<Kbd>{formatShortcut(current)}</Kbd>
)}
</button>
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onReset(shortcutId)}
title="Reset to default"
>
<IconRotate className="size-3.5" />
</Button>
)}
</div>
</div>
);
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Keyboard Shortcuts</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border">
{ids.map((id) => (
<ShortcutRecorder
key={id}
shortcutId={id}
current={shortcuts[id]}
onChange={handleChange}
onReset={handleReset}
/>
))}
</div>
<p className="text-xs text-muted-foreground mt-3">
Click a shortcut to record a new key combination. Changes are saved automatically.
</p>
</CardContent>
</Card>
);
}
@@ -108,7 +108,7 @@ type StepAutoArchiveRowProps = {
function StepAutoArchiveRow({ step, onUpdate, readOnly }: StepAutoArchiveRowProps) {
return (
<div className="flex items-center gap-2 pt-1">
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-auto-archive`}
checked={(step.auto_archive_after_hours ?? 0) > 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 (
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(v) => onCheckedChange(v === true)}
disabled={disabled}
/>
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<HelpTip text={helpText} />
</div>
);
}
// --- StepBehaviorSection ---
type StepBehaviorSectionProps = {
@@ -163,83 +198,56 @@ function StepBehaviorSection({
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-start-step`}
checked={step.is_start_step === true}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ is_start_step: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-start-step`} className="text-sm">
Start step
</Label>
<HelpTip text="New tasks start in this step. Only one step per workflow can be the start step." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-auto-start`}
checked={hasOnEnterAction(step, "auto_start_agent")}
onCheckedChange={() => {
if (readOnly) return;
toggleOnEnterAction("auto_start_agent");
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-start`} className="text-sm">
Auto-start agent
</Label>
<HelpTip text="Automatically start the agent when a task enters this step." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-plan-mode`}
checked={hasOnEnterAction(step, "enable_plan_mode")}
onCheckedChange={() => {
if (readOnly) return;
toggleOnEnterAction("enable_plan_mode");
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-plan-mode`} className="text-sm">
Plan mode
</Label>
<HelpTip text="Agent proposes a plan instead of making changes directly." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-reset-context`}
checked={hasOnEnterAction(step, "reset_agent_context")}
onCheckedChange={() => {
if (readOnly) return;
toggleOnEnterAction("reset_agent_context");
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-reset-context`} className="text-sm">
Reset agent context
</Label>
<HelpTip text="Restart the agent with a fresh conversation context when entering this step. Useful for review steps that need an unbiased perspective." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-manual-move`}
checked={step.allow_manual_move !== false}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ allow_manual_move: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-manual-move`} className="text-sm">
Allow manual move
</Label>
<HelpTip text="Allow dragging tasks into this step on the board." />
</div>
<StepCheckboxRow
id={`${step.id}-start-step`}
checked={step.is_start_step === true}
onCheckedChange={(c) => !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."
/>
<StepCheckboxRow
id={`${step.id}-auto-start`}
checked={hasOnEnterAction(step, "auto_start_agent")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("auto_start_agent")}
disabled={readOnly}
label="Auto-start agent"
helpText="Automatically start the agent when a task enters this step."
/>
<StepCheckboxRow
id={`${step.id}-plan-mode`}
checked={hasOnEnterAction(step, "enable_plan_mode")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("enable_plan_mode")}
disabled={readOnly}
label="Plan mode"
helpText="Agent proposes a plan instead of making changes directly."
/>
<StepCheckboxRow
id={`${step.id}-reset-context`}
checked={hasOnEnterAction(step, "reset_agent_context")}
onCheckedChange={() => !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."
/>
<StepCheckboxRow
id={`${step.id}-manual-move`}
checked={step.allow_manual_move !== false}
onCheckedChange={(c) => !readOnly && onUpdate({ allow_manual_move: c })}
disabled={readOnly}
label="Allow manual move"
helpText="Allow dragging tasks into this step on the board."
/>
<StepCheckboxRow
id={`${step.id}-command-panel`}
checked={step.show_in_command_panel !== false}
onCheckedChange={(c) => !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."
/>
<StepAutoArchiveRow step={step} onUpdate={onUpdate} readOnly={readOnly} />
</div>
<StepAutoArchiveRow step={step} onUpdate={onUpdate} readOnly={readOnly} />
</div>
);
}
+1
View File
@@ -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,
+167
View File
@@ -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 });
});
});
@@ -39,6 +39,7 @@ function carryForwardSettings(current: DisplaySettings) {
lspServerConfigs: current.lspServerConfigs ?? {},
savedLayouts: current.savedLayouts ?? [],
defaultUtilityAgentId: current.defaultUtilityAgentId ?? null,
keyboardShortcuts: current.keyboardShortcuts ?? {},
};
}
+1
View File
@@ -45,6 +45,7 @@ export async function updateUserSettings(
saved_layouts?: SavedLayout[];
default_utility_agent_id?: string;
default_utility_model?: string;
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
},
options?: ApiRequestOptions,
) {
+4
View File
@@ -15,6 +15,8 @@ type BackendTemplateStep = {
color?: string;
prompt?: string;
events?: StepEvents;
is_start_step?: boolean;
show_in_command_panel?: boolean;
};
type BackendWorkflowTemplate = Omit<WorkflowTemplate, "default_steps"> & {
@@ -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,
+1 -1
View File
@@ -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;
+4
View File
@@ -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 },
@@ -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<string, boolean> }
>;
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<ConfigurableShortcutId, KeyboardShortcut> {
return {
SEARCH: getShortcut("SEARCH", overrides),
FILE_SEARCH: getShortcut("FILE_SEARCH", overrides),
};
}
+5 -6
View File
@@ -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);
+1
View File
@@ -38,6 +38,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
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,
},
+2
View File
@@ -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<string, { key: string; modifiers?: Record<string, boolean> }>,
...buildLspFields(undefined),
loaded: false,
};
@@ -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;
@@ -37,6 +37,7 @@ export const defaultSettingsState: SettingsSliceState = {
lspServerConfigs: {},
savedLayouts: [],
defaultUtilityAgentId: null,
keyboardShortcuts: {},
loaded: false,
},
};
@@ -115,6 +115,7 @@ export type UserSettingsState = {
lspServerConfigs: Record<string, Record<string, unknown>>;
savedLayouts: SavedLayout[];
defaultUtilityAgentId: string | null;
keyboardShortcuts: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
loaded: boolean;
};
+2
View File
@@ -82,6 +82,7 @@ export type KanbanUpdatePayload = {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
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<string, { key: string; modifiers?: Record<string, boolean> }>;
updated_at?: string;
};
+6
View File
@@ -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<string, { key: string; modifiers?: Record<string, boolean> }>;
updated_at: string;
};
+1
View File
@@ -17,6 +17,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): 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) => ({
+1
View File
@@ -27,6 +27,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): 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,
},
}));