From 3173c2b56a1de5d4247792ec6612ae445301838b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 6 Sep 2026 17:18:09 +0100 Subject: [PATCH] feat(sidebar): add automatic task color rules (#3180) --- .../backend/internal/backendapp/boot_state.go | 24 +- .../internal/backendapp/boot_state_routes.go | 2 + .../boot_state_user_settings_test.go | 46 +++ .../internal/task/dto/converters_test.go | 39 ++ apps/backend/internal/task/dto/dto.go | 10 +- .../task/dto/primary_agent_profile_test.go | 1 + .../task/handlers/task_http_handlers.go | 24 +- .../repository/session_repository_test.go | 5 +- .../task/repository/sqlite/session.go | 8 +- .../internal/task/service/service_events.go | 6 + .../internal/user/controller/controller.go | 2 + .../user/controller/controller_test.go | 22 + apps/backend/internal/user/dto/dto.go | 10 + apps/backend/internal/user/dto/dto_test.go | 44 ++ .../internal/user/handlers/handlers_test.go | 24 ++ apps/backend/internal/user/models/models.go | 2 + .../models/sidebar_task_color_automation.go | 280 +++++++++++++ .../sidebar_task_color_automation_test.go | 113 ++++++ .../user/models/sidebar_task_colors.go | 109 +++++ .../user/models/sidebar_task_colors_test.go | 90 +++++ apps/backend/internal/user/service/service.go | 35 ++ .../internal/user/service/service_test.go | 59 +++ .../user/service/sidebar_task_colors.go | 38 ++ .../user/service/sidebar_task_colors_test.go | 159 ++++++++ .../user/store/sidebar_task_colors.go | 74 ++++ .../user/store/sidebar_task_colors_test.go | 84 ++++ apps/backend/internal/user/store/sqlite.go | 35 +- .../internal/user/store/sqlite_test.go | 36 ++ .../use-workspace-repository-options.ts | 1 + .../session-task-switcher-sheet-hooks.ts | 46 ++- .../session-task-switcher-sheet-item.ts | 26 ++ .../session-task-switcher-sheet.test.tsx | 6 +- .../automatic-color-repository-picker.tsx | 239 +++++++++++ .../automatic-color-rule-card.tsx | 239 +++++++++++ .../automatic-color-rule-fields.tsx | 328 +++++++++++++++ .../automatic-color-rule-list.tsx | 99 +++++ .../automatic-color-rule-settings-helpers.ts | 10 + .../automatic-color-settings.test.tsx | 224 +++++++++++ .../automatic-color-settings.tsx | 204 ++++++++++ .../sidebar-filter-popover.test.tsx | 52 ++- .../sidebar-filter/sidebar-filter-popover.tsx | 5 +- .../sidebar-settings-disclosure.test.tsx | 38 ++ .../sidebar-settings-disclosure.tsx | 75 ++++ .../sidebar-filter/sidebar-view-editor.tsx | 55 ++- .../task/sidebar-filter/sort-picker.tsx | 4 + .../task-color-rule-options.test.tsx | 96 +++++ .../task-color-rule-options.tsx | 210 ++++++++++ .../task/sidebar-filter/task-row-settings.tsx | 50 +-- ...use-automatic-color-settings-controller.ts | 112 ++++++ .../use-repository-rule-catalog.test.tsx | 77 ++++ .../use-repository-rule-catalog.ts | 56 +++ .../use-sidebar-task-color-automation.ts | 82 ++++ .../components/task/task-archived-context.tsx | 2 + apps/web/components/task/task-item.tsx | 28 +- .../task/task-page-content-helpers.ts | 1 + ...task-session-sidebar-archived-item.test.ts | 60 +++ .../task-session-sidebar-archived-item.ts | 7 + .../task/task-session-sidebar-item.ts | 29 +- .../components/task/task-session-sidebar.tsx | 16 +- .../task/task-switcher-color-menu.tsx | 19 +- .../task/task-switcher-context-menu.tsx | 8 +- .../web/components/task/task-switcher-row.tsx | 1 + .../components/task/task-switcher-types.ts | 11 + apps/web/e2e/fixtures/test-base.ts | 1 + apps/web/e2e/helpers/api-client.ts | 4 + apps/web/e2e/pages/sidebar-filter-popover.ts | 14 + .../mobile-sidebar-automatic-colors.spec.ts | 124 ++++++ .../task/mobile-sidebar-task-actions.spec.ts | 9 +- .../mobile-sidebar-task-color-sync.spec.ts | 54 +++ .../tests/task/mobile-sidebar-views.spec.ts | 2 + .../task/sidebar-automatic-colors.spec.ts | 244 ++++++++++++ .../web/e2e/tests/task/sidebar-filter.spec.ts | 7 + .../task/sidebar-task-color-sync.spec.ts | 122 ++++++ .../use-remote-repositories.test.tsx | 4 + .../integrations/use-remote-repositories.ts | 195 ++++++--- .../session/use-session-messages.test.ts | 4 +- .../hooks/use-ensure-user-settings.test.ts | 6 + .../hooks/use-task-color-migration.test.tsx | 186 +++++++++ apps/web/hooks/use-task-color-migration.ts | 67 ++++ apps/web/hooks/use-task-color.test.tsx | 164 ++++++++ apps/web/hooks/use-task-color.ts | 158 +++++++- apps/web/lib/kanban/map-task.ts | 22 +- .../sidebar/repository-rule-catalog.test.ts | 97 +++++ .../lib/sidebar/repository-rule-catalog.ts | 119 ++++++ .../sidebar/repository-rule-identity.test.ts | 97 +++++ .../lib/sidebar/repository-rule-identity.ts | 203 ++++++++++ .../lib/sidebar/task-color-projection.test.ts | 83 ++++ apps/web/lib/sidebar/task-color-projection.ts | 66 +++ apps/web/lib/sidebar/task-color-rules.test.ts | 107 +++++ apps/web/lib/sidebar/task-color-rules.ts | 107 +++++ apps/web/lib/ssr/mapper.ts | 11 +- .../lib/ssr/user-settings-task-colors.test.ts | 39 ++ apps/web/lib/ssr/user-settings.test.ts | 29 ++ apps/web/lib/ssr/user-settings.ts | 14 + apps/web/lib/state/slices/kanban/types.ts | 4 +- apps/web/lib/state/slices/settings/types.ts | 4 + .../task-color-automation-settings.test.ts | 61 +++ .../web/lib/task-color-automation-settings.ts | 216 ++++++++++ apps/web/lib/task-color-presentation.test.ts | 38 ++ apps/web/lib/task-color-presentation.ts | 103 +++++ apps/web/lib/task-colors.test.ts | 93 ++--- apps/web/lib/task-colors.ts | 72 ++-- apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http-user-settings.ts | 61 +++ apps/web/lib/types/http.ts | 10 + apps/web/lib/ws/handlers/tasks.ts | 3 + apps/web/lib/ws/handlers/users.test.ts | 20 + apps/web/scripts/lib/zh-hant-overrides.json | 6 + apps/web/src/app-shell.test.tsx | 3 + apps/web/src/app-shell.tsx | 3 + apps/web/src/locales/en/task.json | 60 +++ apps/web/src/locales/pseudo/task.json | 60 +++ apps/web/src/locales/pt-pt/task.json | 60 +++ apps/web/src/locales/zh-cn/task.json | 60 +++ apps/web/src/locales/zh-hk/task.json | 60 +++ apps/web/src/locales/zh-tw/task.json | 60 +++ ...41-backend-owned-portable-user-settings.md | 36 +- docs/decisions/INDEX.md | 2 +- .../sidebar-automatic-task-colors/plan.md | 164 ++++++++ ...ask-01-persist-automatic-color-settings.md | 94 +++++ .../task-02-resolve-effective-task-colors.md | 116 ++++++ ...task-03-build-repository-target-catalog.md | 89 +++++ ...liver-responsive-automatic-color-editor.md | 141 +++++++ .../task-05-persist-personal-manual-colors.md | 100 +++++ ...sk-06-adopt-server-backed-manual-colors.md | 103 +++++ docs/specs/ui/README.md | 8 +- .../sidebar-automatic-task-colors.md | 128 ++++++ .../sidebar-automatic-task-colors.md | 376 ++++++++++++++++++ 128 files changed, 8381 insertions(+), 290 deletions(-) create mode 100644 apps/backend/internal/user/models/sidebar_task_color_automation.go create mode 100644 apps/backend/internal/user/models/sidebar_task_color_automation_test.go create mode 100644 apps/backend/internal/user/models/sidebar_task_colors.go create mode 100644 apps/backend/internal/user/models/sidebar_task_colors_test.go create mode 100644 apps/backend/internal/user/service/sidebar_task_colors.go create mode 100644 apps/backend/internal/user/service/sidebar_task_colors_test.go create mode 100644 apps/backend/internal/user/store/sidebar_task_colors.go create mode 100644 apps/backend/internal/user/store/sidebar_task_colors_test.go create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-repository-picker.tsx create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-rule-card.tsx create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-rule-fields.tsx create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-rule-list.tsx create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-rule-settings-helpers.ts create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-settings.test.tsx create mode 100644 apps/web/components/task/sidebar-filter/automatic-color-settings.tsx create mode 100644 apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.test.tsx create mode 100644 apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.tsx create mode 100644 apps/web/components/task/sidebar-filter/task-color-rule-options.test.tsx create mode 100644 apps/web/components/task/sidebar-filter/task-color-rule-options.tsx create mode 100644 apps/web/components/task/sidebar-filter/use-automatic-color-settings-controller.ts create mode 100644 apps/web/components/task/sidebar-filter/use-repository-rule-catalog.test.tsx create mode 100644 apps/web/components/task/sidebar-filter/use-repository-rule-catalog.ts create mode 100644 apps/web/components/task/sidebar-filter/use-sidebar-task-color-automation.ts create mode 100644 apps/web/components/task/task-session-sidebar-archived-item.test.ts create mode 100644 apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts create mode 100644 apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts create mode 100644 apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts create mode 100644 apps/web/hooks/use-task-color-migration.test.tsx create mode 100644 apps/web/hooks/use-task-color-migration.ts create mode 100644 apps/web/hooks/use-task-color.test.tsx create mode 100644 apps/web/lib/sidebar/repository-rule-catalog.test.ts create mode 100644 apps/web/lib/sidebar/repository-rule-catalog.ts create mode 100644 apps/web/lib/sidebar/repository-rule-identity.test.ts create mode 100644 apps/web/lib/sidebar/repository-rule-identity.ts create mode 100644 apps/web/lib/sidebar/task-color-projection.test.ts create mode 100644 apps/web/lib/sidebar/task-color-projection.ts create mode 100644 apps/web/lib/sidebar/task-color-rules.test.ts create mode 100644 apps/web/lib/sidebar/task-color-rules.ts create mode 100644 apps/web/lib/ssr/user-settings-task-colors.test.ts create mode 100644 apps/web/lib/task-color-automation-settings.test.ts create mode 100644 apps/web/lib/task-color-automation-settings.ts create mode 100644 apps/web/lib/task-color-presentation.test.ts create mode 100644 apps/web/lib/task-color-presentation.ts create mode 100644 docs/plans/sidebar-automatic-task-colors/plan.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-01-persist-automatic-color-settings.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-02-resolve-effective-task-colors.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-03-build-repository-target-catalog.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-04-deliver-responsive-automatic-color-editor.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-05-persist-personal-manual-colors.md create mode 100644 docs/plans/sidebar-automatic-task-colors/task-06-adopt-server-backed-manual-colors.md create mode 100644 docs/specs/ui/requirements/sidebar-automatic-task-colors.md create mode 100644 docs/specs/ui/system-design/sidebar-automatic-task-colors.md diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 91e5d75ba2..62cbb4217d 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -801,6 +801,7 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []* sessionCount, info.reviewStatus, info.executorID, + info.executorProfileID, info.executorType, info.executorName, info.agentName, @@ -858,15 +859,16 @@ func taskDTOs(tasks []*taskmodels.Task) []taskdto.TaskDTO { } type bootSessionInfoFields struct { - sessionID *string - reviewStatus taskmodels.ReviewStatus - sessionState *string - executorID *string - executorType *string - executorName *string - agentName *string - agentProfileID *string - workingDirectory *string + sessionID *string + reviewStatus taskmodels.ReviewStatus + sessionState *string + executorID *string + executorProfileID *string + executorType *string + executorName *string + agentName *string + agentProfileID *string + workingDirectory *string } func bootSessionInfo(session *taskmodels.TaskSession) bootSessionInfoFields { @@ -887,6 +889,10 @@ func bootSessionInfo(session *taskmodels.TaskSession) bootSessionInfoFields { value := session.ExecutorID info.executorID = &value } + if session.ExecutorProfileID != "" { + value := session.ExecutorProfileID + info.executorProfileID = &value + } if session.ExecutorSnapshot != nil { if value, ok := session.ExecutorSnapshot["executor_type"].(string); ok && value != "" { info.executorType = &value diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index a6bfd28890..b23bbd7182 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -666,6 +666,8 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "threadActiveViewId": nullString(settings.ThreadActiveViewID), "threadViewDraft": mapThreadViewDraft(settings.ThreadViewDraft), "sidebarTaskPrefs": mapSidebarTaskPrefs(settings.SidebarTaskPrefs), + "sidebarTaskColorAutomation": settings.SidebarTaskColorAutomation, + "sidebarTaskColors": settings.SidebarTaskColors, "taskCreateLastUsed": mapTaskCreateLastUsed(settings.TaskCreateLastUsed), "defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID), "defaultUtilityAgentProfileId": nullString(settings.DefaultUtilityAgentProfileID), diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index bda673a97e..926df6af09 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -42,6 +42,52 @@ func TestMapUserSettingsStateIncludesTasksListShowDetails(t *testing.T) { } } +func TestMapUserSettingsStateIncludesSidebarTaskColorAutomation(t *testing.T) { + automation := usermodels.SidebarTaskColorAutomation{ + Enabled: true, + Rules: []usermodels.SidebarTaskColorRule{{ + ID: "failed-red", + Enabled: true, + Condition: usermodels.SidebarTaskColorCondition{ + Dimension: usermodels.SidebarTaskColorDimensionTaskState, + Value: "FAILED", + Label: "Failed", + }, + Output: usermodels.SidebarTaskColorOutput{ + Kind: usermodels.SidebarTaskColorOutputFixed, + Color: "red", + }, + }}, + } + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{SidebarTaskColorAutomation: automation}, + }, "workspace-1") + + got, ok := state["sidebarTaskColorAutomation"].(usermodels.SidebarTaskColorAutomation) + if !ok || !got.Enabled || len(got.Rules) != 1 || got.Rules[0].ID != "failed-red" { + t.Fatalf( + "sidebarTaskColorAutomation = %#v, want the persisted rule set", + state["sidebarTaskColorAutomation"], + ) + } +} + +func TestMapUserSettingsStateIncludesSidebarTaskColors(t *testing.T) { + red := "red" + colors := map[string]*string{"task-red": &red, "task-cleared": nil} + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{SidebarTaskColors: colors}, + }, "workspace-1") + + got, ok := state["sidebarTaskColors"].(map[string]*string) + if !ok || got["task-red"] == nil || *got["task-red"] != "red" { + t.Fatalf("sidebarTaskColors = %#v, want red color", state["sidebarTaskColors"]) + } + if value, present := got["task-cleared"]; !present || value != nil { + t.Fatalf("sidebarTaskColors tombstone = (%#v, %t), want (nil, true)", value, present) + } +} + // TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault verifies boot state normalizes the MCP task agent profile default. func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) { state := mapUserSettingsState(userdto.UserSettingsResponse{ diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index 926b7e9e3f..9a527136ab 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -80,6 +80,45 @@ func TestFromTaskSerializesAutopilot(t *testing.T) { } } +func TestFromTaskWithSessionInfoSerializesExecutorProfileID(t *testing.T) { + profileID := "executor-profile-1" + task := &models.Task{ID: "task-executor-profile"} + + got := FromTaskWithSessionInfo( + task, + nil, + nil, + models.ReviewStatusNone, + nil, + &profileID, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + ) + + if got.PrimaryExecutorProfileID == nil || *got.PrimaryExecutorProfileID != profileID { + t.Fatalf("PrimaryExecutorProfileID = %v, want %q", got.PrimaryExecutorProfileID, profileID) + } +} + +func TestFromTaskUsesExecutorProfileIDMetadata(t *testing.T) { + task := &models.Task{ + ID: "task-executor-profile-metadata", + Metadata: map[string]interface{}{ + models.MetaKeyExecutorProfileID: "executor-profile-2", + }, + } + + got := FromTask(task) + if got.PrimaryExecutorProfileID == nil || *got.PrimaryExecutorProfileID != "executor-profile-2" { + t.Fatalf("PrimaryExecutorProfileID = %v, want metadata value", got.PrimaryExecutorProfileID) + } +} + func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) { step := &wfmodels.WorkflowStep{ ID: "step-1", diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 5307559525..a47615725c 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -200,6 +200,7 @@ type TaskDTO struct { SessionCount *int `json:"session_count,omitempty"` ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` + PrimaryExecutorProfileID *string `json:"primary_executor_profile_id,omitempty"` PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` PrimaryAgentName *string `json:"primary_agent_name,omitempty"` @@ -853,7 +854,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, models.ReviewStatusNone, nil, nil, nil, nil, nil, nil, nil, nil) + return FromTaskWithSessionInfo(task, primarySessionID, nil, models.ReviewStatusNone, nil, nil, nil, nil, nil, nil, nil, nil, nil) } // FromTaskWithSessionInfo converts a task model to a TaskDTO, including session information. @@ -863,6 +864,7 @@ func FromTaskWithSessionInfo( sessionCount *int, reviewStatus models.ReviewStatus, primaryExecutorID *string, + primaryExecutorProfileID *string, primaryExecutorType *string, primaryExecutorName *string, primaryAgentName *string, @@ -871,6 +873,11 @@ func FromTaskWithSessionInfo( primarySessionState *string, primarySessionPendingAction *string, ) TaskDTO { + if primaryExecutorProfileID == nil { + if value, ok := task.Metadata[models.MetaKeyExecutorProfileID].(string); ok && value != "" { + primaryExecutorProfileID = &value + } + } // Convert repositories var repositories []TaskRepositoryDTO for _, repo := range task.Repositories { @@ -923,6 +930,7 @@ func FromTaskWithSessionInfo( SessionCount: sessionCount, ReviewStatus: reviewStatus, PrimaryExecutorID: primaryExecutorID, + PrimaryExecutorProfileID: primaryExecutorProfileID, PrimaryExecutorType: primaryExecutorType, PrimaryExecutorName: primaryExecutorName, PrimaryAgentName: primaryAgentName, diff --git a/apps/backend/internal/task/dto/primary_agent_profile_test.go b/apps/backend/internal/task/dto/primary_agent_profile_test.go index 1b5c6c0225..4323539263 100644 --- a/apps/backend/internal/task/dto/primary_agent_profile_test.go +++ b/apps/backend/internal/task/dto/primary_agent_profile_test.go @@ -17,6 +17,7 @@ func TestFromTaskWithSessionInfoIncludesPrimaryAgentProfileID(t *testing.T) { nil, nil, nil, + nil, &profileID, nil, nil, diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 17b049cd64..1666ac153f 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -279,6 +279,7 @@ func buildTaskDTOsWithSessionInfo( sessionCount, si.reviewStatus, si.executorID, + si.executorProfileID, si.executorType, si.executorName, si.agentName, @@ -311,15 +312,16 @@ func buildTaskDTOsWithSessionInfo( } type sessionInfoFields struct { - sessionID *string - reviewStatus models.ReviewStatus - sessionState *string - executorID *string - executorType *string - executorName *string - agentName *string - agentProfileID *string - workingDirectory *string + sessionID *string + reviewStatus models.ReviewStatus + sessionState *string + executorID *string + executorProfileID *string + executorType *string + executorName *string + agentName *string + agentProfileID *string + workingDirectory *string } func extractSessionInfo(info *models.TaskSession) sessionInfoFields { @@ -340,6 +342,10 @@ func extractSessionInfo(info *models.TaskSession) sessionInfoFields { val := info.ExecutorID si.executorID = &val } + if info.ExecutorProfileID != "" { + val := info.ExecutorProfileID + si.executorProfileID = &val + } if info.ExecutorSnapshot != nil { if t, ok := info.ExecutorSnapshot["executor_type"].(string); ok && t != "" { si.executorType = &t diff --git a/apps/backend/internal/task/repository/session_repository_test.go b/apps/backend/internal/task/repository/session_repository_test.go index a5cd5f774b..43c6a53a05 100644 --- a/apps/backend/internal/task/repository/session_repository_test.go +++ b/apps/backend/internal/task/repository/session_repository_test.go @@ -603,7 +603,7 @@ func TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields(t *testing.T t.Fatalf("CreateTask: %v", err) } if err := repo.CreateTaskSession(ctx, &models.TaskSession{ - ID: "sess-join", TaskID: "task-join", ExecutorID: exec.ID, + ID: "sess-join", TaskID: "task-join", ExecutorID: exec.ID, ExecutorProfileID: "profile-join", State: models.TaskSessionStateRunning, }); err != nil { t.Fatalf("CreateTaskSession: %v", err) @@ -629,6 +629,9 @@ func TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields(t *testing.T if v, _ := got.ExecutorSnapshot["executor_name"].(string); v != "my-docker" { t.Errorf("expected executor_name 'my-docker' from JOIN, got %q (JOIN to executors removed?)", v) } + if got.ExecutorProfileID != "profile-join" { + t.Errorf("expected executor_profile_id 'profile-join', got %q", got.ExecutorProfileID) + } } // seedBatchSessions creates `taskIDs`, two sessions per task (s1 Completed, diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index b440e94788..3b7f2bc280 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -3184,7 +3184,7 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs } query := fmt.Sprintf(` - SELECT ts.id, ts.task_id, ts.review_status, ts.executor_id, ts.state, + SELECT ts.id, ts.task_id, ts.review_status, ts.executor_id, ts.executor_profile_id, ts.state, ts.agent_profile_snapshot, ts.repository_snapshot, e.type, e.name FROM task_sessions ts @@ -3204,12 +3204,13 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs var taskID string var reviewStatus sql.NullString var executorID sql.NullString + var executorProfileID 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(&sessionID, &taskID, &reviewStatus, &executorID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil { + if err := rows.Scan(&sessionID, &taskID, &reviewStatus, &executorID, &executorProfileID, &sessionState, &agentProfileSnapshotJSON, &repositorySnapshotJSON, &executorType, &executorName); err != nil { return nil, err } session := &models.TaskSession{ @@ -3225,6 +3226,9 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs if executorID.Valid { session.ExecutorID = executorID.String } + if executorProfileID.Valid { + session.ExecutorProfileID = executorProfileID.String + } if executorType.Valid || executorName.Valid { session.ExecutorSnapshot = make(map[string]interface{}, 2) if executorType.Valid { diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index cfef5203a7..2652bb22fb 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -546,6 +546,7 @@ func (s *Service) addTaskSessionEventFieldsWithActivity(ctx context.Context, tas data["primary_session_id"] = nil data["primary_session_state"] = nil data["primary_session_pending_action"] = nil + data["primary_executor_profile_id"] = nil return activity } s.addPrimarySessionEventFields(ctx, taskID, data, sessionInfo) @@ -600,6 +601,11 @@ func (s *Service) addPrimarySessionEventFields(ctx context.Context, taskID strin if sessionInfo.ExecutorID != "" { data["primary_executor_id"] = sessionInfo.ExecutorID } + if sessionInfo.ExecutorProfileID != "" { + data["primary_executor_profile_id"] = sessionInfo.ExecutorProfileID + } else { + data["primary_executor_profile_id"] = nil + } data["primary_agent_profile_id"] = nil data["primary_agent_name"] = nil if sessionInfo.AgentProfileID != "" { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index da2dc3d597..009877f571 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -123,6 +123,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS ThreadActiveViewID: req.ThreadActiveViewID, ThreadViewDraft: req.ThreadViewDraft.ServiceValue(), SidebarTaskPrefs: req.SidebarTaskPrefs, + SidebarTaskColorAutomation: req.SidebarTaskColorAutomation, + SidebarTaskColorPatch: req.SidebarTaskColorPatch, TaskCreateLastUsed: req.TaskCreateLastUsed, JiraSavedViews: req.JiraSavedViews.ServiceValue(), JiraTaskPresets: req.JiraTaskPresets.ServiceValue(), diff --git a/apps/backend/internal/user/controller/controller_test.go b/apps/backend/internal/user/controller/controller_test.go index a161173ed1..10946a63c4 100644 --- a/apps/backend/internal/user/controller/controller_test.go +++ b/apps/backend/internal/user/controller/controller_test.go @@ -80,6 +80,28 @@ func TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault(t *testing.T) { } } +func TestUpdateUserSettingsMapsSidebarTaskColorPatch(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + repo := &settingsRepository{settings: &models.UserSettings{}} + controller := NewController(service.NewService(repo, nil, log)) + red := "red" + + response, err := controller.UpdateUserSettings(context.Background(), dto.UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + Colors: map[string]*string{"task-red": &red}, + }, + }) + if err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + if value := response.Settings.SidebarTaskColors["task-red"]; value == nil || *value != "red" { + t.Fatalf("SidebarTaskColors[task-red] = %#v, want red", value) + } +} + // TestUpdateUserSettingsMapsLspStatusLocation verifies the controller persists an LSP status location patch. func TestUpdateUserSettingsMapsLspStatusLocation(t *testing.T) { log, err := logger.NewFromZap(zap.NewNop()) diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 53cc9bf5c9..6970434324 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -55,6 +55,8 @@ type UserSettingsDTO struct { ThreadActiveViewID string `json:"thread_active_view_id"` ThreadViewDraft *models.ThreadViewDraft `json:"thread_view_draft"` SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + SidebarTaskColorAutomation models.SidebarTaskColorAutomation `json:"sidebar_task_color_automation"` + SidebarTaskColors map[string]*string `json:"sidebar_task_colors"` TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views,omitempty"` JiraTaskPresets json.RawMessage `json:"jira_task_presets,omitempty"` @@ -162,6 +164,8 @@ type UpdateUserSettingsRequest struct { ThreadActiveViewID *string `json:"thread_active_view_id,omitempty"` ThreadViewDraft NullableThreadViewDraft `json:"thread_view_draft,omitempty"` SidebarTaskPrefs *models.SidebarTaskPrefs `json:"sidebar_task_prefs,omitempty"` + SidebarTaskColorAutomation *models.SidebarTaskColorAutomation `json:"sidebar_task_color_automation,omitempty"` + SidebarTaskColorPatch *models.SidebarTaskColorPatch `json:"sidebar_task_color_patch,omitempty"` TaskCreateLastUsed *models.TaskCreateLastUsed `json:"task_create_last_used,omitempty"` JiraSavedViews NullableRawMessage `json:"jira_saved_views,omitempty"` JiraTaskPresets NullableRawMessage `json:"jira_task_presets,omitempty"` @@ -308,6 +312,10 @@ func FromUser(user *models.User) UserDTO { // FromUserSettings maps a settings model to its API DTO, normalizing enum // fields (startup page, MCP default, LSP location) to canonical values. func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { + automaticColors := settings.SidebarTaskColorAutomation + if automaticColors.Rules == nil { + automaticColors.Rules = []models.SidebarTaskColorRule{} + } return UserSettingsDTO{ UserID: settings.UserID, WorkspaceID: settings.WorkspaceID, @@ -349,6 +357,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { ThreadActiveViewID: settings.ThreadActiveViewID, ThreadViewDraft: settings.ThreadViewDraft, SidebarTaskPrefs: settings.SidebarTaskPrefs, + SidebarTaskColorAutomation: automaticColors, + SidebarTaskColors: models.CloneSidebarTaskColors(settings.SidebarTaskColors), TaskCreateLastUsed: settings.TaskCreateLastUsed, JiraSavedViews: settings.JiraSavedViews, JiraTaskPresets: settings.JiraTaskPresets, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 09c62b2ed2..3d3607f17e 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -16,6 +16,50 @@ func TestFromUserSettingsIncludesAtomicRevision(t *testing.T) { } } +func TestSidebarTaskColorAutomationDTOContract(t *testing.T) { + value := models.SidebarTaskColorAutomation{ + Enabled: true, + Rules: []models.SidebarTaskColorRule{{ + ID: "blocked", + Enabled: true, + Condition: models.SidebarTaskColorCondition{Dimension: models.SidebarTaskColorDimensionTaskState, Value: "BLOCKED", Label: "Blocked"}, + Output: models.SidebarTaskColorOutput{Kind: models.SidebarTaskColorOutputFixed, Color: "red"}, + }}, + } + got := FromUserSettings(&models.UserSettings{SidebarTaskColorAutomation: value}) + if !reflect.DeepEqual(got.SidebarTaskColorAutomation, value) { + t.Fatalf("SidebarTaskColorAutomation = %#v, want %#v", got.SidebarTaskColorAutomation, value) + } + + var request UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"sidebar_task_color_automation":{"enabled":true,"rules":[]}}`), &request); err != nil { + t.Fatalf("decode automatic color patch: %v", err) + } + if request.SidebarTaskColorAutomation == nil || !request.SidebarTaskColorAutomation.Enabled { + t.Fatalf("SidebarTaskColorAutomation patch = %#v, want explicit enabled value", request.SidebarTaskColorAutomation) + } +} + +func TestSidebarTaskColorsDTOContract(t *testing.T) { + red := "red" + value := map[string]*string{"task-red": &red, "task-cleared": nil} + got := FromUserSettings(&models.UserSettings{SidebarTaskColors: value}) + if !reflect.DeepEqual(got.SidebarTaskColors, value) { + t.Fatalf("SidebarTaskColors = %#v, want %#v", got.SidebarTaskColors, value) + } + if got.SidebarTaskColors["task-red"] == value["task-red"] { + t.Fatal("DTO color map aliases the model pointer") + } + + var request UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"sidebar_task_color_patch":{"if_missing":true,"colors":{"task-red":"red","task-cleared":null}}}`), &request); err != nil { + t.Fatalf("decode manual color patch: %v", err) + } + if request.SidebarTaskColorPatch == nil || !request.SidebarTaskColorPatch.IfMissing { + t.Fatalf("SidebarTaskColorPatch = %#v, want missing-only patch", request.SidebarTaskColorPatch) + } +} + // TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences verifies the patch request exposes the Azure DevOps browse preferences field. func TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences(t *testing.T) { field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("AzureDevOpsBrowsePreferences") diff --git a/apps/backend/internal/user/handlers/handlers_test.go b/apps/backend/internal/user/handlers/handlers_test.go index 981c3df880..a8ac79cd97 100644 --- a/apps/backend/internal/user/handlers/handlers_test.go +++ b/apps/backend/internal/user/handlers/handlers_test.go @@ -162,6 +162,30 @@ func TestHTTPUpdateUserSettingsKanbanHiddenStepIDsRoundTrip(t *testing.T) { } } +func TestHTTPUpdateUserSettingsSidebarTaskColorsRoundTrip(t *testing.T) { + router := newTestUserSettingsRouter(t) + + patch := []byte(`{"sidebar_task_color_patch":{"colors":{"task-red":"red","task-cleared":null}}}`) + request := httptest.NewRequest(http.MethodPatch, "/api/v1/user/settings", bytes.NewReader(patch)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("PATCH sidebar task colors status = %d, want %d: %s", response.Code, http.StatusOK, response.Body.String()) + } + + var payload dto.UserSettingsResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode settings response: %v", err) + } + if value := payload.Settings.SidebarTaskColors["task-red"]; value == nil || *value != "red" { + t.Fatalf("response task-red = %#v, want red", value) + } + if value, present := payload.Settings.SidebarTaskColors["task-cleared"]; !present || value != nil { + t.Fatalf("response task-cleared = (%#v, %t), want (nil, true)", value, present) + } +} + func TestHTTPUpdateUserSettingsBodyTooLarge(t *testing.T) { router := newTestUserSettingsRouter(t) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index c5a9906322..e05c0ad44c 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -149,6 +149,8 @@ type UserSettings struct { ThreadActiveViewID string `json:"thread_active_view_id"` ThreadViewDraft *ThreadViewDraft `json:"thread_view_draft"` SidebarTaskPrefs SidebarTaskPrefs `json:"sidebar_task_prefs"` + SidebarTaskColorAutomation SidebarTaskColorAutomation `json:"sidebar_task_color_automation"` + SidebarTaskColors map[string]*string `json:"sidebar_task_colors"` TaskCreateLastUsed TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views"` JiraTaskPresets json.RawMessage `json:"jira_task_presets"` diff --git a/apps/backend/internal/user/models/sidebar_task_color_automation.go b/apps/backend/internal/user/models/sidebar_task_color_automation.go new file mode 100644 index 0000000000..6f42086dd4 --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_task_color_automation.go @@ -0,0 +1,280 @@ +package models + +import ( + "fmt" + "unicode/utf8" +) + +const ( + SidebarTaskColorDimensionWorkflowStep = "workflow_step" + SidebarTaskColorDimensionRepository = "repository" + SidebarTaskColorDimensionWorkflow = "workflow" + SidebarTaskColorDimensionExecutorProfile = "executor_profile" + SidebarTaskColorDimensionTaskState = "task_state" + SidebarTaskColorDimensionPriority = "priority" + SidebarTaskColorDimensionOrigin = "origin" + + SidebarTaskColorOutputFixed = "fixed" + SidebarTaskColorOutputWorkflowStep = "workflow_step" + + MaxSidebarTaskColorAutomationRules = 50 + MaxSidebarTaskColorAutomationRuleIDBytes = 64 + MaxSidebarTaskColorAutomationLabelCodePoints = 200 + MaxSidebarTaskColorAutomationIdentityBytes = 512 + MaxSidebarTaskColorAutomationLocalPathBytes = 4096 +) + +// SidebarTaskColorAutomation is a complete personal rule set for deriving +// task-row colors. It is stored inside UserSettings rather than on a task. +type SidebarTaskColorAutomation struct { + Enabled bool `json:"enabled"` + Rules []SidebarTaskColorRule `json:"rules"` +} + +type SidebarTaskColorRule struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Condition SidebarTaskColorCondition `json:"condition"` + Output SidebarTaskColorOutput `json:"output"` +} + +type SidebarTaskColorCondition struct { + Dimension string `json:"dimension"` + Value any `json:"value"` + Label string `json:"label"` +} + +type SidebarTaskColorOutput struct { + Kind string `json:"kind"` + Color string `json:"color,omitempty"` +} + +// DefaultSidebarTaskColorAutomation returns the disabled settings value used +// for new users and malformed stored values. +func DefaultSidebarTaskColorAutomation() SidebarTaskColorAutomation { + return SidebarTaskColorAutomation{Enabled: false, Rules: []SidebarTaskColorRule{}} +} + +// ValidateSidebarTaskColorAutomation validates both the portable rule shape +// and the resource limits applied to user-supplied settings. +func ValidateSidebarTaskColorAutomation(value SidebarTaskColorAutomation) error { + if len(value.Rules) > MaxSidebarTaskColorAutomationRules { + return fmt.Errorf("automatic colors: max %d rules allowed", MaxSidebarTaskColorAutomationRules) + } + seenIDs := make(map[string]struct{}, len(value.Rules)) + for index, rule := range value.Rules { + if rule.ID == "" { + return fmt.Errorf("automatic colors rule %d: rule id is required", index+1) + } + if !utf8.ValidString(rule.ID) || len([]byte(rule.ID)) > MaxSidebarTaskColorAutomationRuleIDBytes { + return fmt.Errorf("automatic colors rule %s: rule id exceeds %d bytes", rule.ID, MaxSidebarTaskColorAutomationRuleIDBytes) + } + if _, exists := seenIDs[rule.ID]; exists { + return fmt.Errorf("automatic colors: duplicate rule id %q", rule.ID) + } + seenIDs[rule.ID] = struct{}{} + + if err := validateSidebarTaskColorCondition(rule.Condition, rule.Enabled); err != nil { + return fmt.Errorf("automatic colors rule %s: %w", rule.ID, err) + } + if err := validateSidebarTaskColorOutput(rule.Condition.Dimension, rule.Output); err != nil { + return fmt.Errorf("automatic colors rule %s: %w", rule.ID, err) + } + } + return nil +} + +func validateSidebarTaskColorCondition(condition SidebarTaskColorCondition, enabled bool) error { + if !isSidebarTaskColorDimension(condition.Dimension) { + return fmt.Errorf("unknown dimension %q", condition.Dimension) + } + if err := validateSidebarTaskColorLabel(condition.Label); err != nil { + return err + } + if condition.Value == nil { + if enabled { + return fmt.Errorf("enabled rule must have a target") + } + return nil + } + + switch condition.Dimension { + case SidebarTaskColorDimensionWorkflowStep: + return validateObjectTarget(condition.Value, "workspace_id", "step_id", false) + case SidebarTaskColorDimensionWorkflow: + return validateObjectTarget(condition.Value, "workspace_id", "workflow_id", false) + case SidebarTaskColorDimensionRepository: + return validateRepositoryTarget(condition.Value) + case SidebarTaskColorDimensionExecutorProfile, + SidebarTaskColorDimensionTaskState, + SidebarTaskColorDimensionPriority, + SidebarTaskColorDimensionOrigin: + return validateBoundedString(condition.Value, "target", MaxSidebarTaskColorAutomationIdentityBytes) + default: + return fmt.Errorf("unknown dimension %q", condition.Dimension) + } +} + +func validateSidebarTaskColorOutput(dimension string, output SidebarTaskColorOutput) error { + switch output.Kind { + case SidebarTaskColorOutputFixed: + if !isSidebarTaskColor(output.Color) { + return fmt.Errorf("unsupported fixed color %q", output.Color) + } + case SidebarTaskColorOutputWorkflowStep: + if dimension != SidebarTaskColorDimensionWorkflowStep { + return fmt.Errorf("workflow-step output requires workflow_step dimension") + } + if output.Color != "" { + return fmt.Errorf("workflow-step output cannot include a fixed color") + } + default: + return fmt.Errorf("unknown output kind %q", output.Kind) + } + return nil +} + +func validateObjectTarget(value any, firstKey, secondKey string, allowExtra bool) error { + target, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("target must be an object") + } + if !allowExtra && len(target) != 2 { + return fmt.Errorf("target must contain only %s and %s", firstKey, secondKey) + } + if err := validateObjectString(target, firstKey, MaxSidebarTaskColorAutomationIdentityBytes); err != nil { + return err + } + if err := validateObjectString(target, secondKey, MaxSidebarTaskColorAutomationIdentityBytes); err != nil { + return err + } + return nil +} + +func validateRepositoryTarget(value any) error { + target, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("repository target must be an object") + } + kind, ok := target["kind"].(string) + if !ok || kind == "" { + return fmt.Errorf("repository target kind is required") + } + switch kind { + case "workspace": + return validateWorkspaceRepositoryTarget(target) + case "provider": + return validateProviderRepositoryTarget(target) + case "local": + return validateLocalRepositoryTarget(target) + default: + return fmt.Errorf("unknown repository target kind %q", kind) + } +} + +func validateWorkspaceRepositoryTarget(target map[string]any) error { + if len(target) != 3 { + return fmt.Errorf("workspace repository target has an invalid shape") + } + if err := validateObjectString(target, "workspace_id", MaxSidebarTaskColorAutomationIdentityBytes); err != nil { + return err + } + return validateObjectString(target, "repository_id", MaxSidebarTaskColorAutomationIdentityBytes) +} + +func validateProviderRepositoryTarget(target map[string]any) error { + if len(target) != 5 { + return fmt.Errorf("provider repository target has an invalid shape") + } + for _, key := range []string{"provider_id", "host", "scope", "provider_repository_id"} { + if err := validateObjectString(target, key, MaxSidebarTaskColorAutomationIdentityBytes); err != nil { + return err + } + } + return nil +} + +func validateLocalRepositoryTarget(target map[string]any) error { + if len(target) != 2 { + return fmt.Errorf("local repository target has an invalid shape") + } + path, err := objectString(target, "path") + if err != nil { + return err + } + if len([]byte(path)) > MaxSidebarTaskColorAutomationLocalPathBytes { + return fmt.Errorf("path exceeds %d bytes", MaxSidebarTaskColorAutomationLocalPathBytes) + } + return nil +} + +func validateObjectString(target map[string]any, key string, maxBytes int) error { + value, err := objectString(target, key) + if err != nil { + return err + } + return validateStringBytes(value, key, maxBytes) +} + +func objectString(target map[string]any, key string) (string, error) { + value, ok := target[key].(string) + if !ok || value == "" { + return "", fmt.Errorf("%s must be a non-empty string", key) + } + return value, nil +} + +func validateBoundedString(value any, field string, maxBytes int) error { + stringValue, ok := value.(string) + if !ok || stringValue == "" { + return fmt.Errorf("%s must be a non-empty string", field) + } + return validateStringBytes(stringValue, field, maxBytes) +} + +func validateStringBytes(value, field string, maxBytes int) error { + if !utf8.ValidString(value) || len([]byte(value)) > maxBytes { + return fmt.Errorf("%s exceeds %d bytes", field, maxBytes) + } + return nil +} + +func validateSidebarTaskColorLabel(label string) error { + if !utf8.ValidString(label) || utf8.RuneCountInString(label) > MaxSidebarTaskColorAutomationLabelCodePoints { + return fmt.Errorf("label exceeds %d code points", MaxSidebarTaskColorAutomationLabelCodePoints) + } + return nil +} + +func isSidebarTaskColorDimension(value string) bool { + switch value { + case SidebarTaskColorDimensionWorkflowStep, + SidebarTaskColorDimensionRepository, + SidebarTaskColorDimensionWorkflow, + SidebarTaskColorDimensionExecutorProfile, + SidebarTaskColorDimensionTaskState, + SidebarTaskColorDimensionPriority, + SidebarTaskColorDimensionOrigin: + return true + default: + return false + } +} + +func isSidebarTaskColor(value string) bool { + _, ok := sidebarTaskColorValues[value] + return ok +} + +var sidebarTaskColorValues = map[string]struct{}{ + "gray": {}, + "red": {}, + "orange": {}, + "yellow": {}, + "green": {}, + "cyan": {}, + "blue": {}, + "indigo": {}, + "purple": {}, + "pink": {}, +} diff --git a/apps/backend/internal/user/models/sidebar_task_color_automation_test.go b/apps/backend/internal/user/models/sidebar_task_color_automation_test.go new file mode 100644 index 0000000000..ddd2d85d2e --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_task_color_automation_test.go @@ -0,0 +1,113 @@ +package models + +import ( + "strings" + "testing" +) + +func TestValidateSidebarTaskColorAutomationAcceptsCompleteAndIncompleteRules(t *testing.T) { + settings := SidebarTaskColorAutomation{ + Enabled: true, + Rules: []SidebarTaskColorRule{ + { + ID: "blocked", + Enabled: true, + Condition: SidebarTaskColorCondition{ + Dimension: SidebarTaskColorDimensionTaskState, + Value: "BLOCKED", + Label: "Blocked", + }, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "red"}, + }, + { + ID: "unfinished", + Enabled: false, + Condition: SidebarTaskColorCondition{ + Dimension: SidebarTaskColorDimensionRepository, + Value: nil, + }, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "blue"}, + }, + }, + } + + if err := ValidateSidebarTaskColorAutomation(settings); err != nil { + t.Fatalf("validate automatic colors: %v", err) + } +} + +func TestValidateSidebarTaskColorAutomationRejectsInvalidRules(t *testing.T) { + tests := []struct { + name string + value SidebarTaskColorAutomation + want string + }{ + { + name: "duplicate ids", + value: SidebarTaskColorAutomation{Rules: []SidebarTaskColorRule{ + {ID: "same", Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "TODO"}, Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "red"}}, + {ID: "same", Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "REVIEW"}, Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "blue"}}, + }}, + want: "duplicate rule id", + }, + { + name: "enabled incomplete rule", + value: SidebarTaskColorAutomation{Rules: []SidebarTaskColorRule{{ + ID: "missing-target", + Enabled: true, + Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionWorkflow, Value: nil}, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "red"}, + }}}, + want: "enabled rule must have a target", + }, + { + name: "unknown color", + value: SidebarTaskColorAutomation{Rules: []SidebarTaskColorRule{{ + ID: "unknown-color", + Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "TODO"}, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "chartreuse"}, + }}}, + want: "unsupported fixed color", + }, + { + name: "workflow output on another dimension", + value: SidebarTaskColorAutomation{Rules: []SidebarTaskColorRule{{ + ID: "invalid-output", + Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "TODO"}, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputWorkflowStep}, + }}}, + want: "workflow-step output requires workflow_step", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateSidebarTaskColorAutomation(tt.value); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want text %q", err, tt.want) + } + }) + } +} + +func TestValidateSidebarTaskColorAutomationEnforcesBounds(t *testing.T) { + tooMany := make([]SidebarTaskColorRule, MaxSidebarTaskColorAutomationRules+1) + for i := range tooMany { + tooMany[i] = SidebarTaskColorRule{ + ID: string(rune('a'+i%26)) + "-" + string(rune('0'+i%10)), + Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "TODO"}, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "red"}, + } + } + if err := ValidateSidebarTaskColorAutomation(SidebarTaskColorAutomation{Rules: tooMany}); err == nil { + t.Fatal("expected rule-count validation error") + } + + oversizedLabel := SidebarTaskColorAutomation{Rules: []SidebarTaskColorRule{{ + ID: "label", + Condition: SidebarTaskColorCondition{Dimension: SidebarTaskColorDimensionTaskState, Value: "TODO", Label: strings.Repeat("界", MaxSidebarTaskColorAutomationLabelCodePoints+1)}, + Output: SidebarTaskColorOutput{Kind: SidebarTaskColorOutputFixed, Color: "red"}, + }}} + if err := ValidateSidebarTaskColorAutomation(oversizedLabel); err == nil { + t.Fatal("expected label-size validation error") + } +} diff --git a/apps/backend/internal/user/models/sidebar_task_colors.go b/apps/backend/internal/user/models/sidebar_task_colors.go new file mode 100644 index 0000000000..550033e1f3 --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_task_colors.go @@ -0,0 +1,109 @@ +package models + +import ( + "encoding/json" + "fmt" + "unicode/utf8" +) + +const ( + // MaxSidebarTaskColorPatchEntries bounds one HTTP or WebSocket mutation. + MaxSidebarTaskColorPatchEntries = 500 + // MaxSidebarTaskColors bounds the number of stored task decisions, including tombstones. + MaxSidebarTaskColors = 10_000 + // MaxSidebarTaskColorTaskIDBytes bounds a task ID by its UTF-8 encoded size. + MaxSidebarTaskColorTaskIDBytes = 128 + // MaxSidebarTaskColorsJSONBytes bounds the encoded stored decision map. + MaxSidebarTaskColorsJSONBytes = 1 << 20 +) + +// SidebarTaskColorPatch changes selected personal task-color decisions. A +// nil color is a clear tombstone, and IfMissing is used only by legacy import. +type SidebarTaskColorPatch struct { + Colors map[string]*string `json:"colors"` + IfMissing bool `json:"if_missing"` +} + +// IsValidSidebarTaskColor reports whether value is in the seven-color manual palette. +func IsValidSidebarTaskColor(value string) bool { + _, ok := sidebarTaskManualColorValues[value] + return ok +} + +// IsValidSidebarTaskColorTaskID reports whether id fits the stored map key contract. +func IsValidSidebarTaskColorTaskID(id string) bool { + return id != "" && utf8.ValidString(id) && len([]byte(id)) <= MaxSidebarTaskColorTaskIDBytes +} + +// ValidateSidebarTaskColorPatch validates a bounded per-task mutation. +func ValidateSidebarTaskColorPatch(patch SidebarTaskColorPatch) error { + if len(patch.Colors) > MaxSidebarTaskColorPatchEntries { + return fmt.Errorf("manual colors: max %d patch entries allowed", MaxSidebarTaskColorPatchEntries) + } + for taskID, color := range patch.Colors { + if err := validateSidebarTaskColorTaskID(taskID); err != nil { + return err + } + if color != nil && !IsValidSidebarTaskColor(*color) { + return fmt.Errorf("manual colors[%s]: unsupported color %q", taskID, *color) + } + } + return nil +} + +// ValidateSidebarTaskColors validates the complete stored decision map. +func ValidateSidebarTaskColors(colors map[string]*string) error { + if len(colors) > MaxSidebarTaskColors { + return fmt.Errorf("manual colors: max %d stored entries allowed", MaxSidebarTaskColors) + } + for taskID, color := range colors { + if err := validateSidebarTaskColorTaskID(taskID); err != nil { + return err + } + if color != nil && !IsValidSidebarTaskColor(*color) { + return fmt.Errorf("manual colors[%s]: unsupported color %q", taskID, *color) + } + } + encoded, err := json.Marshal(colors) + if err != nil { + return fmt.Errorf("manual colors must be serializable: %w", err) + } + if len(encoded) > MaxSidebarTaskColorsJSONBytes { + return fmt.Errorf("manual colors: max %d encoded bytes allowed", MaxSidebarTaskColorsJSONBytes) + } + return nil +} + +func validateSidebarTaskColorTaskID(taskID string) error { + if taskID == "" { + return fmt.Errorf("manual colors: task id is required") + } + if !utf8.ValidString(taskID) || len([]byte(taskID)) > MaxSidebarTaskColorTaskIDBytes { + return fmt.Errorf("manual colors: task id exceeds %d bytes", MaxSidebarTaskColorTaskIDBytes) + } + return nil +} + +// CloneSidebarTaskColors returns a deep copy, preserving nil tombstones. +func CloneSidebarTaskColors(colors map[string]*string) map[string]*string { + clone := make(map[string]*string, len(colors)) + for taskID, color := range colors { + if color == nil { + clone[taskID] = nil + continue + } + value := *color + clone[taskID] = &value + } + return clone +} + +var sidebarTaskManualColorValues = map[string]struct{}{ + "red": {}, + "orange": {}, + "yellow": {}, + "green": {}, + "blue": {}, + "purple": {}, + "pink": {}, +} diff --git a/apps/backend/internal/user/models/sidebar_task_colors_test.go b/apps/backend/internal/user/models/sidebar_task_colors_test.go new file mode 100644 index 0000000000..21fb4008f1 --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_task_colors_test.go @@ -0,0 +1,90 @@ +package models + +import ( + "strings" + "testing" +) + +func TestValidateSidebarTaskColorPatchAcceptsColorsAndTombstones(t *testing.T) { + red := "red" + patch := SidebarTaskColorPatch{ + Colors: map[string]*string{ + "task-red": &red, + "task-cleared": nil, + }, + IfMissing: true, + } + + if err := ValidateSidebarTaskColorPatch(patch); err != nil { + t.Fatalf("ValidateSidebarTaskColorPatch: %v", err) + } +} + +func TestValidateSidebarTaskColorPatchRejectsInvalidEntriesAndOversizedPatch(t *testing.T) { + blue := "blue" + tests := []struct { + name string + patch SidebarTaskColorPatch + }{ + { + name: "unsupported color", + patch: SidebarTaskColorPatch{Colors: map[string]*string{ + "task-1": func() *string { value := "gray"; return &value }(), + }}, + }, + { + name: "empty task id", + patch: SidebarTaskColorPatch{Colors: map[string]*string{"": &blue}}, + }, + { + name: "task id exceeds byte limit", + patch: SidebarTaskColorPatch{Colors: map[string]*string{ + strings.Repeat("a", MaxSidebarTaskColorTaskIDBytes+1): &blue, + }}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateSidebarTaskColorPatch(tt.patch); err == nil { + t.Fatal("expected validation error") + } + }) + } + + tooMany := make(map[string]*string, MaxSidebarTaskColorPatchEntries+1) + for index := 0; index <= MaxSidebarTaskColorPatchEntries; index++ { + tooMany["task-"+string(rune('a'+index%26))+string(rune(index/26))] = &blue + } + if err := ValidateSidebarTaskColorPatch(SidebarTaskColorPatch{Colors: tooMany}); err == nil { + t.Fatal("expected oversized patch validation error") + } +} + +func TestValidateSidebarTaskColorsRejectsOversizedStoredMap(t *testing.T) { + red := "red" + colors := make(map[string]*string, MaxSidebarTaskColors) + for index := 0; index < MaxSidebarTaskColors; index++ { + colors[strings.Repeat("x", MaxSidebarTaskColorTaskIDBytes-8)+string(rune(index))] = &red + } + + if err := ValidateSidebarTaskColors(colors); err == nil { + t.Fatal("expected encoded stored-map size validation error") + } +} + +func TestCloneSidebarTaskColorsDoesNotAliasPointers(t *testing.T) { + red := "red" + original := map[string]*string{"task-1": &red, "task-cleared": nil} + clone := CloneSidebarTaskColors(original) + + if clone["task-1"] == original["task-1"] { + t.Fatal("clone aliased the color pointer") + } + *clone["task-1"] = "blue" + if *original["task-1"] != "red" { + t.Fatalf("original color = %q, want red", *original["task-1"]) + } + if _, ok := clone["task-cleared"]; !ok || clone["task-cleared"] != nil { + t.Fatalf("clone tombstone = %#v, want present nil", clone["task-cleared"]) + } +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 677731697c..f4cb1e0a0f 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -83,6 +83,8 @@ type UpdateUserSettingsRequest struct { ThreadActiveViewID *string ThreadViewDraft **models.ThreadViewDraft SidebarTaskPrefs *models.SidebarTaskPrefs + SidebarTaskColorAutomation *models.SidebarTaskColorAutomation + SidebarTaskColorPatch *models.SidebarTaskColorPatch TaskCreateLastUsed *models.TaskCreateLastUsed JiraSavedViews **json.RawMessage JiraTaskPresets **json.RawMessage @@ -225,6 +227,12 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err := applyThreadViewState(settings, req); err != nil { return false, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } + if err := applySidebarTaskColorAutomation(settings, req); err != nil { + return false, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } + if err := applySidebarTaskColors(settings, req); err != nil { + return false, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } if err := applyUserPreferenceBlobs(settings, req); err != nil { return false, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } @@ -236,6 +244,31 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting return settings, nil } +// applySidebarTaskColorAutomation replaces the complete personal automatic +// color rule set after validating its discriminated target and output shape. +func applySidebarTaskColorAutomation(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarTaskColorAutomation == nil { + return nil + } + value := *req.SidebarTaskColorAutomation + if err := models.ValidateSidebarTaskColorAutomation(value); err != nil { + return err + } + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("automatic colors must be serializable: %w", err) + } + var replacement models.SidebarTaskColorAutomation + if err := json.Unmarshal(encoded, &replacement); err != nil { + return fmt.Errorf("automatic colors must be serializable: %w", err) + } + if replacement.Rules == nil { + replacement.Rules = []models.SidebarTaskColorRule{} + } + settings.SidebarTaskColorAutomation = replacement + return nil +} + // updateUserSettingsCAS applies a full-blob user-settings write under // expected-revision CAS with bounded retry. apply is evaluated against every // freshly read model and reports whether it mutated it; the immutable @@ -973,6 +1006,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "thread_active_view_id": settings.ThreadActiveViewID, "thread_view_draft": settings.ThreadViewDraft, "sidebar_task_prefs": settings.SidebarTaskPrefs, + "sidebar_task_color_automation": settings.SidebarTaskColorAutomation, + "sidebar_task_colors": models.CloneSidebarTaskColors(settings.SidebarTaskColors), "task_create_last_used": settings.TaskCreateLastUsed, "jira_saved_views": settings.JiraSavedViews, "jira_task_presets": settings.JiraTaskPresets, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index e6bcc1339e..aa45b24b58 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -32,6 +32,65 @@ func rawClear() **json.RawMessage { return ptr((*json.RawMessage)(nil)) } +func TestApplySidebarTaskColorAutomationReplacesRulesAndValidates(t *testing.T) { + current := models.SidebarTaskColorAutomation{ + Enabled: true, + Rules: []models.SidebarTaskColorRule{{ + ID: "old", + Enabled: true, + Condition: models.SidebarTaskColorCondition{Dimension: models.SidebarTaskColorDimensionTaskState, Value: "TODO"}, + Output: models.SidebarTaskColorOutput{Kind: models.SidebarTaskColorOutputFixed, Color: "red"}, + }}, + } + replacement := models.SidebarTaskColorAutomation{ + Enabled: true, + Rules: []models.SidebarTaskColorRule{{ + ID: "new", + Enabled: false, + Condition: models.SidebarTaskColorCondition{Dimension: models.SidebarTaskColorDimensionRepository, Value: nil}, + Output: models.SidebarTaskColorOutput{Kind: models.SidebarTaskColorOutputFixed, Color: "cyan"}, + }}, + } + settings := &models.UserSettings{SidebarTaskColorAutomation: current} + if err := applySidebarTaskColorAutomation(settings, &UpdateUserSettingsRequest{ + SidebarTaskColorAutomation: &replacement, + }); err != nil { + t.Fatalf("apply automatic colors: %v", err) + } + if !reflect.DeepEqual(settings.SidebarTaskColorAutomation, replacement) { + t.Fatalf("automatic colors = %#v, want %#v", settings.SidebarTaskColorAutomation, replacement) + } + + invalid := models.SidebarTaskColorAutomation{ + Enabled: replacement.Enabled, + Rules: append([]models.SidebarTaskColorRule(nil), replacement.Rules...), + } + invalid.Rules[0].Enabled = true + if err := applySidebarTaskColorAutomation(settings, &UpdateUserSettingsRequest{ + SidebarTaskColorAutomation: &invalid, + }); err == nil { + t.Fatal("expected incomplete enabled rule to be rejected") + } + if !reflect.DeepEqual(settings.SidebarTaskColorAutomation, replacement) { + t.Fatalf("rejected automatic colors changed settings to %#v", settings.SidebarTaskColorAutomation) + } +} + +func TestApplySidebarTaskColorAutomationOmissionPreservesRules(t *testing.T) { + want := models.SidebarTaskColorAutomation{Enabled: true, Rules: []models.SidebarTaskColorRule{{ + ID: "keep", + Condition: models.SidebarTaskColorCondition{Dimension: models.SidebarTaskColorDimensionTaskState, Value: "TODO"}, + Output: models.SidebarTaskColorOutput{Kind: models.SidebarTaskColorOutputFixed, Color: "blue"}, + }}} + settings := &models.UserSettings{SidebarTaskColorAutomation: want} + if err := applySidebarTaskColorAutomation(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply omitted automatic colors: %v", err) + } + if !reflect.DeepEqual(settings.SidebarTaskColorAutomation, want) { + t.Fatalf("automatic colors = %#v, want %#v", settings.SidebarTaskColorAutomation, want) + } +} + // TestApplyBasicSettingsTasksListShowDetails verifies applyBasicSettings preserves TasksListShowDetails when omitted and applies explicit values. func TestApplyBasicSettingsTasksListShowDetails(t *testing.T) { t.Run("omission preserves saved value", func(t *testing.T) { diff --git a/apps/backend/internal/user/service/sidebar_task_colors.go b/apps/backend/internal/user/service/sidebar_task_colors.go new file mode 100644 index 0000000000..15ea9d3351 --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_task_colors.go @@ -0,0 +1,38 @@ +package service + +import "github.com/kandev/kandev/internal/user/models" + +// applySidebarTaskColors applies a narrow manual-color patch to a copied map. +// Missing-only imports preserve both active colors and clear tombstones. +func applySidebarTaskColors(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarTaskColorPatch == nil { + return nil + } + patch := *req.SidebarTaskColorPatch + if err := models.ValidateSidebarTaskColorPatch(patch); err != nil { + return err + } + + next := models.CloneSidebarTaskColors(settings.SidebarTaskColors) + for taskID, color := range patch.Colors { + if patch.IfMissing { + if _, exists := next[taskID]; exists { + continue + } + } + next[taskID] = cloneSidebarTaskColor(color) + } + if err := models.ValidateSidebarTaskColors(next); err != nil { + return err + } + settings.SidebarTaskColors = next + return nil +} + +func cloneSidebarTaskColor(color *string) *string { + if color == nil { + return nil + } + value := *color + return &value +} diff --git a/apps/backend/internal/user/service/sidebar_task_colors_test.go b/apps/backend/internal/user/service/sidebar_task_colors_test.go new file mode 100644 index 0000000000..90d4522621 --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_task_colors_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "sync" + "testing" + + "github.com/kandev/kandev/internal/user/models" + "github.com/kandev/kandev/internal/user/store" +) + +func TestUpdateUserSettingsSidebarTaskColorPatchSupportsNormalClearAndImport(t *testing.T) { + red := "red" + blue := "blue" + green := "green" + repo := newCASFakeRepo(&models.UserSettings{ + UserID: store.DefaultUserID, + SidebarTaskColors: map[string]*string{ + "task-existing": &red, + "task-cleared": nil, + "task-other": &green, + }, + }) + svc := newCASService(repo, nil) + + got, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + Colors: map[string]*string{"task-existing": &blue}, + }, + }) + if err != nil { + t.Fatalf("normal color patch: %v", err) + } + if got.SidebarTaskColors["task-existing"] == nil || *got.SidebarTaskColors["task-existing"] != "blue" { + t.Fatalf("normal patch color = %#v, want blue", got.SidebarTaskColors["task-existing"]) + } + if got.SidebarTaskColors["task-other"] == nil || *got.SidebarTaskColors["task-other"] != "green" { + t.Fatalf("normal patch changed an unsupplied task: %#v", got.SidebarTaskColors["task-other"]) + } + + got, err = svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + Colors: map[string]*string{"task-existing": nil}, + }, + }) + if err != nil { + t.Fatalf("clear color patch: %v", err) + } + if value, present := got.SidebarTaskColors["task-existing"]; !present || value != nil { + t.Fatalf("clear patch = (%#v, %t), want (nil, true)", value, present) + } + + got, err = svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + IfMissing: true, + Colors: map[string]*string{ + "task-existing": &red, + "task-cleared": &blue, + "task-new": &green, + }, + }, + }) + if err != nil { + t.Fatalf("missing-only color patch: %v", err) + } + if value, present := got.SidebarTaskColors["task-existing"]; !present || value != nil { + t.Fatalf("cleared existing color = (%#v, %t), want (nil, true)", value, present) + } + if value, present := got.SidebarTaskColors["task-cleared"]; !present || value != nil { + t.Fatalf("existing tombstone = (%#v, %t), want (nil, true)", value, present) + } + if value := got.SidebarTaskColors["task-new"]; value == nil || *value != "green" { + t.Fatalf("new imported color = %#v, want green", value) + } +} + +func TestUpdateUserSettingsSidebarTaskColorImportPreservesTombstones(t *testing.T) { + red := "red" + blue := "blue" + repo := newCASFakeRepo(&models.UserSettings{ + UserID: store.DefaultUserID, + SidebarTaskColors: map[string]*string{"task-color": &red, "task-tombstone": nil}, + }) + svc := newCASService(repo, nil) + + got, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + IfMissing: true, + Colors: map[string]*string{ + "task-color": &blue, + "task-tombstone": &blue, + "task-new": &blue, + }, + }, + }) + if err != nil { + t.Fatalf("missing-only color patch: %v", err) + } + if value := got.SidebarTaskColors["task-color"]; value == nil || *value != "red" { + t.Fatalf("existing color = %#v, want red", value) + } + if value, present := got.SidebarTaskColors["task-tombstone"]; !present || value != nil { + t.Fatalf("existing tombstone = (%#v, %t), want (nil, true)", value, present) + } + if value := got.SidebarTaskColors["task-new"]; value == nil || *value != "blue" { + t.Fatalf("missing color = %#v, want blue", value) + } +} + +func TestConcurrentSidebarTaskColorPatchesMergeDifferentTasks(t *testing.T) { + red := "red" + blue := "blue" + repo := newCASFakeRepo(&models.UserSettings{UserID: store.DefaultUserID}) + repo.readDone = make(chan struct{}, 4) + repo.blockUpsert = make(chan struct{}) + svc := newCASService(repo, nil) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + Colors: map[string]*string{"task-red": &red}, + }, + }) + if err != nil { + t.Errorf("red patch: %v", err) + } + }() + go func() { + defer wg.Done() + _, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{ + SidebarTaskColorPatch: &models.SidebarTaskColorPatch{ + Colors: map[string]*string{"task-blue": &blue}, + }, + }) + if err != nil { + t.Errorf("blue patch: %v", err) + } + }() + + <-repo.readDone + <-repo.readDone + close(repo.blockUpsert) + wg.Wait() + + row := repo.snapshot() + if value := row.SidebarTaskColors["task-red"]; value == nil || *value != "red" { + t.Fatalf("red color = %#v, want red", value) + } + if value := row.SidebarTaskColors["task-blue"]; value == nil || *value != "blue" { + t.Fatalf("blue color = %#v, want blue", value) + } + if row.Revision != 2 { + t.Fatalf("revision = %d, want 2", row.Revision) + } + assertOneStaleConflictAndRetry(t, repo) +} diff --git a/apps/backend/internal/user/store/sidebar_task_colors.go b/apps/backend/internal/user/store/sidebar_task_colors.go new file mode 100644 index 0000000000..ec284cdd0e --- /dev/null +++ b/apps/backend/internal/user/store/sidebar_task_colors.go @@ -0,0 +1,74 @@ +package store + +import ( + "bytes" + "encoding/json" + "sort" + + "github.com/kandev/kandev/internal/user/models" +) + +// decodeSidebarTaskColors normalizes each stored entry independently so one +// malformed task decision cannot hide valid decisions or other settings. +func decodeSidebarTaskColors(raw json.RawMessage) map[string]*string { + colors := map[string]*string{} + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte(jsonNull)) { + return colors + } + + var encoded map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &encoded); err != nil || encoded == nil { + return colors + } + keys := make([]string, 0, len(encoded)) + for taskID := range encoded { + keys = append(keys, taskID) + } + sort.Strings(keys) + for _, taskID := range keys { + if len(colors) >= models.MaxSidebarTaskColors { + break + } + if !models.IsValidSidebarTaskColorTaskID(taskID) { + continue + } + color, valid := decodeSidebarTaskColorValue(encoded[taskID]) + if !valid { + continue + } + colors[taskID] = color + } + trimSidebarTaskColors(colors, keys) + return colors +} + +func decodeSidebarTaskColorValue(raw json.RawMessage) (*string, bool) { + trimmed := bytes.TrimSpace(raw) + if bytes.Equal(trimmed, []byte(jsonNull)) { + return nil, true + } + var color string + if err := json.Unmarshal(trimmed, &color); err != nil || !models.IsValidSidebarTaskColor(color) { + return nil, false + } + return &color, true +} + +// trimSidebarTaskColors keeps normalized storage within the encoded-size cap. +// Sorted removal makes the fallback deterministic for hand-edited blobs. +func trimSidebarTaskColors(colors map[string]*string, keys []string) { + encoded, err := json.Marshal(colors) + if err != nil { + return + } + for len(encoded) > models.MaxSidebarTaskColorsJSONBytes && len(keys) > 0 { + last := keys[len(keys)-1] + delete(colors, last) + keys = keys[:len(keys)-1] + encoded, err = json.Marshal(colors) + if err != nil { + return + } + } +} diff --git a/apps/backend/internal/user/store/sidebar_task_colors_test.go b/apps/backend/internal/user/store/sidebar_task_colors_test.go new file mode 100644 index 0000000000..f10d7087c5 --- /dev/null +++ b/apps/backend/internal/user/store/sidebar_task_colors_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/user/models" + _ "github.com/mattn/go-sqlite3" +) + +func TestScanUserSettingsSidebarTaskColorsFiltersInvalidValues(t *testing.T) { + raw := `{"workspace_id":"workspace-1","sidebar_task_colors":{"task-red":"red","task-cleared":null,"task-invalid":"gray","":"blue","task-object":{},"task-number":7}}` + settings, err := scanUserSettings(settingsScanner{raw: raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + if settings.WorkspaceID != "workspace-1" { + t.Fatalf("workspace = %q, want workspace-1", settings.WorkspaceID) + } + if value := settings.SidebarTaskColors["task-red"]; value == nil || *value != "red" { + t.Fatalf("red color = %#v, want red", value) + } + if value, present := settings.SidebarTaskColors["task-cleared"]; !present || value != nil { + t.Fatalf("tombstone = (%#v, %t), want (nil, true)", value, present) + } + if len(settings.SidebarTaskColors) != 2 { + t.Fatalf("normalized colors = %#v, want two valid entries", settings.SidebarTaskColors) + } +} + +func TestSQLiteRepositorySidebarTaskColorsRoundTrip(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + repo, err := newSQLiteRepositoryWithDB(conn, conn) + if err != nil { + t.Fatalf("new repo: %v", err) + } + + ctx := context.Background() + settings, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get defaults: %v", err) + } + red := "red" + settings.SidebarTaskColors = map[string]*string{"task-red": &red, "task-cleared": nil} + upsertUserSettingsForTest(t, repo, ctx, settings) + + got, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get saved settings: %v", err) + } + if value := got.SidebarTaskColors["task-red"]; value == nil || *value != "red" { + t.Fatalf("round-trip red color = %#v, want red", value) + } + if value, present := got.SidebarTaskColors["task-cleared"]; !present || value != nil { + t.Fatalf("round-trip tombstone = (%#v, %t), want (nil, true)", value, present) + } +} + +func TestDecodeSidebarTaskColorsCapsStoredMap(t *testing.T) { + red := "red" + colors := make(map[string]*string, models.MaxSidebarTaskColors+1) + for index := 0; index < models.MaxSidebarTaskColors+1; index++ { + colors["task-"+strings.Repeat("x", 4)+string(rune(index))] = &red + } + rawColors, err := json.Marshal(colors) + if err != nil { + t.Fatalf("marshal colors: %v", err) + } + settings, err := scanUserSettings(settingsScanner{raw: `{"sidebar_task_colors":` + string(rawColors) + `}`}, DefaultUserID) + if err != nil { + t.Fatalf("scan oversized colors: %v", err) + } + if len(settings.SidebarTaskColors) > models.MaxSidebarTaskColors { + t.Fatalf("stored color count = %d, want at most %d", len(settings.SidebarTaskColors), models.MaxSidebarTaskColors) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 4d7d8b4df7..6a8112b0f6 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -24,6 +24,7 @@ const ( DefaultSidebarViewID = "view-all-tasks" defaultChangesPanelLayout = "tree" + jsonNull = "null" ) type sqliteRepository struct { @@ -593,6 +594,11 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { threadViews = []models.ThreadView{} } sidebarTaskPrefs := normalizeSidebarTaskPrefs(settings.SidebarTaskPrefs) + sidebarTaskColorAutomation := settings.SidebarTaskColorAutomation + if sidebarTaskColorAutomation.Rules == nil { + sidebarTaskColorAutomation.Rules = []models.SidebarTaskColorRule{} + } + sidebarTaskColors := models.CloneSidebarTaskColors(settings.SidebarTaskColors) keyboardShortcuts := settings.KeyboardShortcuts if keyboardShortcuts == nil { keyboardShortcuts = map[string]interface{}{} @@ -638,6 +644,8 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "thread_active_view_id": settings.ThreadActiveViewID, "thread_view_draft": settings.ThreadViewDraft, "sidebar_task_prefs": sidebarTaskPrefs, + "sidebar_task_color_automation": sidebarTaskColorAutomation, + "sidebar_task_colors": sidebarTaskColors, "task_create_last_used": settings.TaskCreateLastUsed, "jira_saved_views": settings.JiraSavedViews, "jira_task_presets": settings.JiraTaskPresets, @@ -739,6 +747,8 @@ func defaultUserSettings(userID string) *models.UserSettings { ThreadViews: DefaultThreadViews(), ThreadActiveViewID: DefaultThreadViewID, SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), + SidebarTaskColorAutomation: models.DefaultSidebarTaskColorAutomation(), + SidebarTaskColors: map[string]*string{}, AppStatusBarEnabled: false, ResolveSessionHostnames: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), @@ -812,6 +822,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin ThreadActiveViewID json.RawMessage `json:"thread_active_view_id"` ThreadViewDraft *models.ThreadViewDraft `json:"thread_view_draft"` SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + SidebarTaskColorAutomation json.RawMessage `json:"sidebar_task_color_automation"` + SidebarTaskColors json.RawMessage `json:"sidebar_task_colors"` TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` JiraSavedViews json.RawMessage `json:"jira_saved_views"` JiraTaskPresets json.RawMessage `json:"jira_task_presets"` @@ -960,6 +972,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.ThreadViewDraft = payload.ThreadViewDraft settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(payload.SidebarTaskPrefs) + settings.SidebarTaskColorAutomation = decodeSidebarTaskColorAutomation(payload.SidebarTaskColorAutomation) + settings.SidebarTaskColors = decodeSidebarTaskColors(payload.SidebarTaskColors) settings.TaskCreateLastUsed = payload.TaskCreateLastUsed settings.JiraSavedViews = payload.JiraSavedViews settings.JiraTaskPresets = payload.JiraTaskPresets @@ -1004,8 +1018,27 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin return settings, nil } +// decodeSidebarTaskColorAutomation keeps one corrupt personal rule set from +// preventing the rest of user settings from loading. +func decodeSidebarTaskColorAutomation(raw json.RawMessage) models.SidebarTaskColorAutomation { + if len(raw) == 0 || string(raw) == jsonNull { + return models.DefaultSidebarTaskColorAutomation() + } + var value models.SidebarTaskColorAutomation + if err := json.Unmarshal(raw, &value); err != nil { + return models.DefaultSidebarTaskColorAutomation() + } + if err := models.ValidateSidebarTaskColorAutomation(value); err != nil { + return models.DefaultSidebarTaskColorAutomation() + } + if value.Rules == nil { + value.Rules = []models.SidebarTaskColorRule{} + } + return value +} + func decodeStringIDs(raw json.RawMessage) []string { - if len(raw) == 0 || string(raw) == "null" { + if len(raw) == 0 || string(raw) == jsonNull { return []string{} } var ids []string diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index bba89f54ef..84fd186939 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -227,6 +227,42 @@ func TestScanUserSettingsPreservesExplicitEmptySidebarSettings(t *testing.T) { } } +func TestSidebarTaskColorAutomationStorageRoundTripAndMalformedFallback(t *testing.T) { + raw := `{"sidebar_task_color_automation":{"enabled":true,"rules":[{"id":"blocked","enabled":true,"condition":{"dimension":"task_state","value":"BLOCKED","label":"Blocked"},"output":{"kind":"fixed","color":"red"}}]}}` + settings, err := scanUserSettings(settingsScanner{raw: raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan automatic colors: %v", err) + } + if !settings.SidebarTaskColorAutomation.Enabled || len(settings.SidebarTaskColorAutomation.Rules) != 1 { + t.Fatalf("automatic colors = %#v, want enabled rule", settings.SidebarTaskColorAutomation) + } + encoded, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatalf("marshal automatic colors: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode normalized settings: %v", err) + } + if got := payload["sidebar_task_color_automation"].(map[string]any)["enabled"]; got != true { + t.Fatalf("stored automatic colors enabled = %#v, want true", got) + } + + for _, malformed := range []string{ + `{"sidebar_task_color_automation":null}`, + `{"sidebar_task_color_automation":{"enabled":true,"rules":"bad"}}`, + `{"sidebar_task_color_automation":{"enabled":true,"rules":[{"id":"bad","enabled":true,"condition":{"dimension":"task_state","value":null},"output":{"kind":"fixed","color":"red"}}]}}`, + } { + settings, err := scanUserSettings(settingsScanner{raw: malformed}, DefaultUserID) + if err != nil { + t.Fatalf("scan malformed automatic colors %q: %v", malformed, err) + } + if settings.SidebarTaskColorAutomation.Enabled || len(settings.SidebarTaskColorAutomation.Rules) != 0 { + t.Fatalf("malformed automatic colors = %#v, want disabled empty default", settings.SidebarTaskColorAutomation) + } + } +} + // TestScanUserSettingsChangesPanelLayoutDefault verifies changes_panel_layout defaults to tree and preserves an explicit flat value. func TestScanUserSettingsChangesPanelLayoutDefault(t *testing.T) { t.Run("empty settings default to tree", func(t *testing.T) { diff --git a/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts index ad1a438cc2..f4d0ae4e9b 100644 --- a/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts +++ b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts @@ -20,6 +20,7 @@ export function useWorkspaceRepositoryOptions(workspaceId: string | null, open: repositories, discoveredRepositories: discovery.repositories, repositoriesRefreshing: repositoriesLoading || discovery.isLoading || discovery.isRefreshing, + error: discovery.error, refreshRepositoryOptions, }; } diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 0ca17a4169..a3dfcd858a 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -26,6 +26,10 @@ import { } from "./session-task-switcher-sheet-selection"; import { taskPendingSelectionSnapshot } from "../task-select-helpers"; import { useTranslation } from "react-i18next"; +import { useArchivedTaskState } from "../task-archived-context"; +import { buildArchivedSidebarItem } from "../task-session-sidebar-archived-item"; +import type { SidebarItemContext } from "../task-session-sidebar-item"; +import type { TaskSwitcherItem } from "../task-switcher"; function findSheetTask( state: ReturnType["getState"]>, @@ -55,13 +59,22 @@ export function useSheetData(workspaceId: string | null) { const steps = useAppStore((state) => state.kanban.steps); const workspaces = useAppStore((state) => state.workspaces.items); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const automaticColorSettings = useAppStore( + (state) => state.userSettings.sidebarTaskColorAutomation, + ); const acknowledgedAgentErrors = useAppStore((state) => state.acknowledgedAgentErrors); const dismissedAgentErrors = useAppStore((state) => state.dismissedAgentErrors); + const archivedState = useArchivedTaskState(); const selectedTaskId = activeTaskId; const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; + const repositoriesById = new Map( + Object.values(repositoriesByWorkspace) + .flat() + .map((repo: Repository) => [repo.id, repo]), + ); const ctx: SheetItemCtx = { repositoryPathsById: new Map( repositories.map((repo: Repository) => [repo.id, repositorySlug(repo)]), @@ -71,8 +84,33 @@ export function useSheetData(workspaceId: string | null) { acknowledgedAgentErrors, dismissedAgentErrors, wipQueueByTaskId, + workspaceId: workspaceId ?? undefined, + repositoriesById, + stepColorById: new Map(allSteps.map((step) => [step.id, step.color])), + automaticColorSettings, }; - return allTasks.map((task) => toSheetItem(task, ctx)); + const items: TaskSwitcherItem[] = allTasks.map((task) => toSheetItem(task, ctx)); + if ( + archivedState.isArchived && + archivedState.archivedTaskId && + !items.some((task) => task.id === archivedState.archivedTaskId) + ) { + const archivedContext: SidebarItemContext = { + repositorySlugById: ctx.repositoryPathsById, + titleById: new Map(allTasks.map((task) => [task.id, task.title])), + workflowNameById: ctx.workflowNameById, + stepTitleById: ctx.stepTitleById, + wipQueueByTaskId: ctx.wipQueueByTaskId, + acknowledgedAgentErrors: ctx.acknowledgedAgentErrors, + dismissedAgentErrors: ctx.dismissedAgentErrors, + workspaceId: ctx.workspaceId, + repositoriesById: ctx.repositoriesById, + stepColorById: ctx.stepColorById, + automaticColorSettings: ctx.automaticColorSettings, + }; + items.unshift(buildArchivedSidebarItem(archivedState, archivedContext)); + } + return items; }, [ repositoriesByWorkspace, allTasks, @@ -82,6 +120,8 @@ export function useSheetData(workspaceId: string | null) { acknowledgedAgentErrors, dismissedAgentErrors, wipQueueByTaskId, + automaticColorSettings, + archivedState, ]); const dialogSteps = useMemo( @@ -263,6 +303,7 @@ function buildKanbanTaskUpsert( const taskSessionId = meta?.taskSessionId ?? null; return { id: task.id, + workspaceId: task.workspace_id, parentTaskId: task.parent_id ?? undefined, workspaceMode: workspaceModeFromMetadata(task.metadata), workflowId: task.workflow_id, @@ -271,11 +312,14 @@ function buildKanbanTaskUpsert( description: task.description, position: task.position ?? 0, state: task.state, + priority: task.priority, + origin: task.origin, repositoryId: task.repositories?.[0]?.repository_id ?? undefined, repositories: mapTaskRepositories(task.repositories), updatedAt: task.updated_at, ...mergeSessionFields(task, existing, taskSessionId), primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorProfileId: task.primary_executor_profile_id ?? undefined, primaryExecutorType: task.primary_executor_type ?? undefined, primaryExecutorName: task.primary_executor_name ?? undefined, isRemoteExecutor: task.is_remote_executor ?? false, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts index 50ebc340eb..01c85260af 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -1,5 +1,6 @@ import { repositoryId as toRepositoryId, + type Repository, type TaskSessionState, type TaskState, } from "@/lib/types/http"; @@ -9,6 +10,9 @@ import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; import { resolveTaskRepositorySlugs } from "@/lib/sidebar/sidebar-task-repositories"; import { effectiveTaskPendingAction } from "../task-select-helpers"; import { taskPRInfoFromSummary } from "../task-pr-info"; +import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import { taskColorFacts } from "@/lib/sidebar/task-color-projection"; +import { resolveAutomaticTaskColor } from "@/lib/sidebar/task-color-rules"; export type SheetItemCtx = { repositoryPathsById: Map; @@ -17,8 +21,15 @@ export type SheetItemCtx = { wipQueueByTaskId?: Map; acknowledgedAgentErrors?: Record; dismissedAgentErrors?: Record; + workspaceId?: string; + repositoriesById?: ReadonlyMap; + stepColorById?: ReadonlyMap; + automaticColorSettings?: SidebarTaskColorAutomation; }; +const EMPTY_REPOSITORIES_BY_ID = new Map(); +const EMPTY_STEP_COLORS = new Map(); + function sheetDiffStats(summary: KanbanState["tasks"][number]["statusSummary"]) { const git = summary?.git; if (!git || ((git.additions ?? 0) <= 0 && (git.deletions ?? 0) <= 0)) return undefined; @@ -75,6 +86,14 @@ export function toSheetItem( ctx: SheetItemCtx, ) { const status = sheetStatus(task, ctx); + const facts = taskColorFacts(task, { + workspaceId: ctx.workspaceId, + repositoriesById: ctx.repositoriesById ?? EMPTY_REPOSITORIES_BY_ID, + stepColorById: ctx.stepColorById ?? EMPTY_STEP_COLORS, + }); + const automaticColor = ctx.automaticColorSettings + ? resolveAutomaticTaskColor(ctx.automaticColorSettings, facts) + : null; return { id: task.id, title: task.title, @@ -90,12 +109,19 @@ export function toSheetItem( workflowName: ctx.workflowNameById.get(task._workflowId), workflowStepId: task.workflowStepId, workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId), + workspaceId: facts.workspaceId, + origin: task.origin, + primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined, + workflowStepColor: facts.workflowStepColor, isArchived: task.isArchived === true, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorId: task.primaryExecutorId ?? undefined, remoteExecutorType: task.primaryExecutorType ?? undefined, remoteExecutorName: task.primaryExecutorName ?? undefined, repositoryLinks: task.repositories, + repositoryRuleIdentities: facts.repositories, + automaticColor: automaticColor?.color, + automaticColorSource: automaticColor?.source, queuedCount: task.statusSummary?.queued_prompt_count, wipQueue: ctx.wipQueueByTaskId?.get(task.id), }; diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx index 9793c3a0a8..d892b9daa5 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ taskPRs: { byTaskId: {} }, taskMRs: { byWorkspaceId: {} }, comments: { byTaskId: {} }, + userSettings: { sidebarTaskColors: {} }, }, })); @@ -34,7 +35,10 @@ vi.mock("@/components/state-provider", () => ({ // TaskNestContextMenuItems (rendered inside the shared context menu) reads // the store api via useNestTask; provide a minimal stub so it renders. useAppStoreApi: () => ({ - getState: () => ({ kanbanMulti: { snapshots: {} } }), + getState: () => ({ + kanbanMulti: { snapshots: {} }, + userSettings: { revision: null, sidebarTaskColors: {} }, + }), }), })); diff --git a/apps/web/components/task/sidebar-filter/automatic-color-repository-picker.tsx b/apps/web/components/task/sidebar-filter/automatic-color-repository-picker.tsx new file mode 100644 index 0000000000..a83e80c448 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/automatic-color-repository-picker.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { IconChevronLeft, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { cn } from "@/lib/utils"; +import type { SidebarTaskColorRule } from "@/lib/task-color-automation-settings"; +import type { TaskColorRuleOption } from "./task-color-rule-options"; +import type { RepositoryRuleCatalogOption } from "@/lib/sidebar/repository-rule-catalog"; +import { useTranslation } from "react-i18next"; + +export type Translate = (key: string, options?: Record) => string; + +const GROUP_LABEL_KEYS: Record = { + workspace: "task:automaticColorsRepositoryWorkspace", + local: "task:automaticColorsRepositoryLocal", + remote: "task:automaticColorsRepositoryRemote", + plugin: "task:automaticColorsRepositoryRemote", + unavailable: "task:automaticColorsRepositoryUnavailable", +}; + +export function RepositoryConditionField({ + rule, + selectedOption, + isDrawerLayout, + options, + query, + loading, + error, + onQueryChange, + onRefresh, + onOpen, + onSelect, + t, +}: { + rule: SidebarTaskColorRule; + selectedOption: TaskColorRuleOption | undefined; + isDrawerLayout: boolean; + options: readonly RepositoryRuleCatalogOption[]; + query: string; + loading: boolean; + error: Error | null; + onQueryChange: (query: string) => void; + onRefresh: () => void; + onOpen: () => void; + onSelect: (option: RepositoryRuleCatalogOption) => void; + t: Translate; +}) { + const button = ( + + ); + + const fieldLabel = ( + + {t("task:automaticColorsSelectRepository")} + + ); + const field = ( +
+ {fieldLabel} + {button} +
+ ); + + if (isDrawerLayout) return field; + return ( +
+ {fieldLabel} + + {button} + + + + +
+ ); +} + +export function RepositoryPickerPane({ + options, + query, + loading, + error, + onQueryChange, + onRefresh, + onBack, + onSelect, +}: { + options: readonly RepositoryRuleCatalogOption[]; + query: string; + loading: boolean; + error: Error | null; + onQueryChange: (query: string) => void; + onRefresh: () => void; + onBack: () => void; + onSelect: (option: RepositoryRuleCatalogOption) => void; +}) { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +function RepositoryPicker({ + options, + query, + loading, + error, + onQueryChange, + onRefresh, + onSelect, + isDrawerLayout, + t, +}: { + options: readonly RepositoryRuleCatalogOption[]; + query: string; + loading: boolean; + error: Error | null; + onQueryChange: (query: string) => void; + onRefresh: () => void; + onSelect: (option: RepositoryRuleCatalogOption) => void; + isDrawerLayout: boolean; + t: Translate; +}) { + const groups = [...new Set(options.map((option) => option.group))]; + return ( +
+
+ onQueryChange(event.target.value)} + placeholder={t("task:automaticColorsSearchRepositories")} + className="min-h-11 text-xs md:min-h-0 md:h-8" + data-testid="automatic-color-repository-search" + /> + +
+ {error && ( +

+ {t("task:automaticColorsRepositoryError")} +

+ )} + {loading &&

{t("task:loading")}

} + {!loading && groups.length === 0 && ( +

{t("task:automaticColorsNoRepositories")}

+ )} +
+ {groups.map((group) => ( +
+

+ {t(GROUP_LABEL_KEYS[group])} +

+
+ {options + .filter((option) => option.group === group) + .map((option) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/automatic-color-rule-card.tsx b/apps/web/components/task/sidebar-filter/automatic-color-rule-card.tsx new file mode 100644 index 0000000000..3f88f41353 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/automatic-color-rule-card.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { IconArrowDown, IconArrowUp, IconGripVertical, IconTrash } from "@tabler/icons-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@kandev/ui/button"; +import type { + SidebarTaskColorDimension, + SidebarTaskColorRule, +} from "@/lib/task-color-automation-settings"; +import type { RepositoryRuleCatalogOption } from "@/lib/sidebar/repository-rule-catalog"; +import type { TaskColorRuleOption, TaskColorRuleOptionMap } from "./task-color-rule-options"; +import { taskColorDimensionLabelKey, taskColorRuleOptionKey } from "./task-color-rule-options"; +import { + AutomaticColorConditionFields, + AutomaticColorEnableField, + AutomaticColorOutputField, + isCompleteRule, + optionWithCurrentValue, +} from "./automatic-color-rule-fields"; +import type { Translate } from "./automatic-color-repository-picker"; + +export function AutomaticColorRuleCard({ + rule, + index, + total, + scalarOptions, + repositoryOptions, + repositoryQuery, + repositoryLoading, + repositoryError, + onRepositoryQueryChange, + onRefreshRepositories, + isDrawerLayout, + onChange, + onRemove, + onOpenRepository, + onMove, + t, +}: { + rule: SidebarTaskColorRule; + index: number; + total: number; + scalarOptions: TaskColorRuleOptionMap; + repositoryOptions: readonly RepositoryRuleCatalogOption[]; + repositoryQuery: string; + repositoryLoading: boolean; + repositoryError: Error | null; + onRepositoryQueryChange: (query: string) => void; + onRefreshRepositories: () => void; + isDrawerLayout: boolean; + onChange: (rule: SidebarTaskColorRule) => void; + onRemove: () => void; + onOpenRepository: () => void; + onMove: (direction: -1 | 1) => void; + t: Translate; +}) { + const sortable = useSortable({ id: rule.id }); + const rawOptions = getRuleOptions(rule, scalarOptions, repositoryOptions); + const options = optionWithCurrentValue(rawOptions, rule, t); + const selectedOption = options.find( + (option) => option.key === taskColorRuleOptionKey(rule.condition.value), + ); + const complete = isCompleteRule(rule, selectedOption); + + return ( +
+ +
+ onChange(ruleWithCondition(rule, dimension))} + onTargetChange={(key) => { + const next = ruleWithTarget(rule, options, key); + if (next) onChange(next); + }} + t={t} + /> + + onChange({ ...rule, enabled })} + t={t} + /> +
+
+ ); +} + +function getRuleOptions( + rule: SidebarTaskColorRule, + scalarOptions: TaskColorRuleOptionMap, + repositoryOptions: readonly RepositoryRuleCatalogOption[], +): readonly TaskColorRuleOption[] { + if (rule.condition.dimension !== "repository") { + return scalarOptions[rule.condition.dimension]; + } + return repositoryOptions.map((option) => ({ + key: option.key, + value: option.target, + label: option.label, + secondaryLabel: option.secondaryLabel, + available: option.available, + })); +} + +function ruleWithCondition( + rule: SidebarTaskColorRule, + dimension: SidebarTaskColorDimension, +): SidebarTaskColorRule { + const output = + dimension === "workflow_step" && rule.output.kind === "workflow_step" + ? rule.output + : { kind: "fixed" as const, color: "gray" as const }; + return { + ...rule, + enabled: false, + condition: { dimension, value: null, label: "" }, + output, + }; +} + +function ruleWithTarget( + rule: SidebarTaskColorRule, + options: readonly TaskColorRuleOption[], + key: string, +): SidebarTaskColorRule | null { + const selected = options.find((option) => option.key === key); + if (!selected?.available) return null; + return { + ...rule, + condition: { ...rule.condition, value: selected.value, label: selected.label }, + }; +} + +function AutomaticColorRuleHeader({ + ruleId, + index, + total, + dimension, + sortable, + onRemove, + onMove, + t, +}: { + ruleId: string; + index: number; + total: number; + dimension: SidebarTaskColorDimension; + sortable: ReturnType; + onRemove: () => void; + onMove: (direction: -1 | 1) => void; + t: Translate; +}) { + return ( +
+ + + {t("task:automaticColorsRule", { number: index + 1 })}{" "} + {t(taskColorDimensionLabelKey(dimension))} + + + + +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/automatic-color-rule-fields.tsx b/apps/web/components/task/sidebar-filter/automatic-color-rule-fields.tsx new file mode 100644 index 0000000000..e52d7b9f94 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/automatic-color-rule-fields.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Switch } from "@kandev/ui/switch"; +import type { + SidebarTaskColorDimension, + SidebarTaskColorRule, + FixedAutomaticTaskColor, +} from "@/lib/task-color-automation-settings"; +import { + FIXED_AUTOMATIC_TASK_COLORS, + AUTOMATIC_TASK_COLOR_DIMENSIONS, +} from "@/lib/task-color-automation-settings"; +import { + fixedAutomaticTaskColor, + type TaskMarkerPresentation, +} from "@/lib/task-color-presentation"; +import { cn } from "@/lib/utils"; +import type { RepositoryRuleCatalogOption } from "@/lib/sidebar/repository-rule-catalog"; +import { RepositoryConditionField, type Translate } from "./automatic-color-repository-picker"; +import { taskColorDimensionLabelKey, type TaskColorRuleOption } from "./task-color-rule-options"; + +type AutomaticColorLabelKey = Record; + +export const AUTOMATIC_COLOR_LABEL_KEYS: AutomaticColorLabelKey = { + gray: "task:colorGray", + red: "task:colorRed", + orange: "task:colorOrange", + yellow: "task:colorYellow", + green: "task:colorGreen", + cyan: "task:colorCyan", + blue: "task:colorBlue", + indigo: "task:colorIndigo", + purple: "task:colorPurple", + pink: "task:colorPink", +}; + +export function AutomaticColorConditionFields({ + rule, + options, + repositoryOptions, + selectedOption, + isDrawerLayout, + repositoryQuery, + repositoryLoading, + repositoryError, + onRepositoryQueryChange, + onRefreshRepositories, + onOpenRepository, + onChange, + onConditionChange, + onTargetChange, + t, +}: { + rule: SidebarTaskColorRule; + options: readonly TaskColorRuleOption[]; + repositoryOptions: readonly RepositoryRuleCatalogOption[]; + selectedOption: TaskColorRuleOption | undefined; + isDrawerLayout: boolean; + repositoryQuery: string; + repositoryLoading: boolean; + repositoryError: Error | null; + onRepositoryQueryChange: (query: string) => void; + onRefreshRepositories: () => void; + onOpenRepository: () => void; + onChange: (rule: SidebarTaskColorRule) => void; + onConditionChange: (dimension: SidebarTaskColorDimension) => void; + onTargetChange: (key: string) => void; + t: Translate; +}) { + return ( +
+ + {rule.condition.dimension === "repository" ? ( + + onChange({ + ...rule, + condition: { ...rule.condition, value: option.target, label: option.label }, + }) + } + t={t} + /> + ) : ( + + )} +
+ ); +} + +function AutomaticColorDimensionField({ + ruleId, + dimension, + onChange, + t, +}: { + ruleId: string; + dimension: SidebarTaskColorDimension; + onChange: (dimension: SidebarTaskColorDimension) => void; + t: Translate; +}) { + return ( + + ); +} + +function AutomaticColorTargetField({ + ruleId, + options, + selectedOption, + onChange, + t, +}: { + ruleId: string; + options: readonly TaskColorRuleOption[]; + selectedOption: TaskColorRuleOption | undefined; + onChange: (key: string) => void; + t: Translate; +}) { + return ( + + ); +} + +export function AutomaticColorOutputField({ + rule, + onChange, + t, +}: { + rule: SidebarTaskColorRule; + onChange: (rule: SidebarTaskColorRule) => void; + t: Translate; +}) { + return ( + + ); +} + +export function AutomaticColorEnableField({ + rule, + complete, + onChange, + t, +}: { + rule: SidebarTaskColorRule; + complete: boolean; + onChange: (enabled: boolean) => void; + t: Translate; +}) { + return ( +
+
+ {t("task:automaticColorsEnable")} + {!complete && ( + + {t("task:automaticColorsCompleteRule")} + + )} +
+ +
+ ); +} + +export function optionWithCurrentValue( + options: readonly TaskColorRuleOption[], + rule: SidebarTaskColorRule, + t: Translate, +): TaskColorRuleOption[] { + if (rule.condition.value === null || rule.condition.value === undefined) return [...options]; + const key = JSON.stringify(rule.condition.value); + if (options.some((option) => option.key === key)) return [...options]; + return [ + ...options, + { + key, + value: rule.condition.value, + label: rule.condition.label || t("task:automaticColorsUnavailable"), + available: false, + }, + ]; +} + +export function isCompleteRule( + rule: SidebarTaskColorRule, + selectedOption: TaskColorRuleOption | undefined, +): boolean { + if (rule.condition.value === null || rule.condition.value === undefined) return false; + if (!selectedOption) return false; + return rule.output.kind === "workflow_step" + ? rule.condition.dimension === "workflow_step" + : FIXED_AUTOMATIC_TASK_COLORS.includes(rule.output.color); +} + +function ColorSwatch({ marker }: { marker: TaskMarkerPresentation }) { + return ( + + } + summary={summary} + testId="automatic-color-settings" + expanded={controller.expanded} + onExpandedChange={controller.setExpanded} + contentClassName="space-y-2 pt-1" + > + + + ); +} + +function AutomaticColorSettingsBody({ + controller, + isDrawerLayout, + t, +}: { + controller: AutomaticColorSettingsController; + isDrawerLayout: boolean; + t: (key: string, options?: Record) => string; +}) { + const activeRule = controller.activeRepositoryRule; + const catalog = controller.repositoryCatalog; + + return ( + <> +
+
+
+ + {t("task:automaticColorsEnabled")} + + +
+ +
+
+ + {controller.error && ( +

+ {controller.error} +

+ )} + + {isDrawerLayout && activeRule ? ( + { + controller.updateRule(activeRule.id, { + ...activeRule, + condition: { + ...activeRule.condition, + value: option.target, + label: option.label, + }, + }); + controller.closeRepository(); + }} + /> + ) : ( + + )} + + ); +} + +function AutomaticColorsInfo({ + t, +}: { + t: (key: string, options?: Record) => string; +}) { + return ( + + + + + +
+

{t("task:automaticColorsPersonalGlobal")}

+

{t("task:automaticColorsTiming")}

+

{t("task:automaticColorsPrecedence")}

+

{t("task:automaticColorsHelp")}

+
+
+
+ ); +} + +function AutomaticColorRules({ + controller, + isDrawerLayout, + t, +}: { + controller: AutomaticColorSettingsController; + isDrawerLayout: boolean; + t: (key: string, options?: Record) => string; +}) { + const catalog = controller.repositoryCatalog; + return ( + <> + {controller.value.rules.length > 0 && ( + + )} +
+ + {t("task:automaticColorsRuleCount", { count: controller.value.rules.length })} + + +
+ {controller.value.rules.length >= MAX_AUTOMATIC_TASK_COLOR_RULES && ( +

+ {t("task:automaticColorsRuleLimit")} +

+ )} + + ); +} diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx index 4a3db5e709..973d6a379a 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx @@ -30,10 +30,18 @@ const state = { discardSidebarDraft: vi.fn(), deleteSidebarView: vi.fn(), renameSidebarView: vi.fn(), + workspaces: { activeId: null }, + kanbanMulti: { snapshots: {} }, + workflows: { items: [] }, + agentProfiles: { items: [] }, + executors: { items: [] }, + userSettings: { sidebarTaskColorAutomation: { enabled: false, rules: [] } }, + setUserSettings: vi.fn(), }; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (value: typeof state) => unknown) => selector(state), + useAppStoreApi: () => ({ getState: () => state }), })); afterEach(() => { @@ -42,7 +50,7 @@ afterEach(() => { }); describe("SidebarFilterPopover task-row editor", () => { - it("keeps the editor collapsed until the user opens it", () => { + it("keeps view settings collapsed until the user opens them", () => { render( Open} @@ -53,11 +61,52 @@ describe("SidebarFilterPopover task-row editor", () => { expect(screen.getByTestId("task-row-settings-toggle")).toBeTruthy(); expect(screen.queryByTestId("task-row-details-toggle")).toBeNull(); + expect(screen.queryByTestId("sort-key-select")).toBeNull(); + expect(screen.queryByTestId("group-key-select")).toBeNull(); + expect(screen.getByText("Status, Sort direction asc", { exact: true })).toBeTruthy(); + + fireEvent.click(screen.getByTestId("sidebar-sort-settings-toggle")); + expect(screen.getByTestId("sort-key-select")).toBeTruthy(); + fireEvent.click(screen.getByTestId("sidebar-group-settings-toggle")); + expect(screen.getByTestId("group-key-select")).toBeTruthy(); + fireEvent.click(screen.getByTestId("task-row-settings-toggle")); expect(screen.getByTestId("task-row-details-toggle")).toBeTruthy(); expect(state.updateSidebarDraft).not.toHaveBeenCalled(); }); + it("gives each collapsed view setting the same bottom separator", () => { + render( + Open} + open + onOpenChange={vi.fn()} + />, + ); + + for (const testId of ["sidebar-sort-settings", "sidebar-group-settings", "task-row-settings"]) { + const classes = screen.getByTestId(testId).className.split(" "); + expect(classes).toContain("border-b"); + expect(classes).toContain("pb-1"); + expect(classes).toContain("pt-1"); + } + expect(screen.getByTestId("automatic-color-settings").className.split(" ")).not.toContain( + "border-t", + ); + }); + + it("removes the popover primitive's default section gap", () => { + render( + Open} + open + onOpenChange={vi.fn()} + />, + ); + + expect(screen.getByTestId("sidebar-filter-popover").className.split(" ")).toContain("gap-0"); + }); + it("describes every group-by and right-side option", () => { const renderEditor = () => render( @@ -69,6 +118,7 @@ describe("SidebarFilterPopover task-row editor", () => { ); renderEditor(); + fireEvent.click(screen.getByTestId("sidebar-group-settings-toggle")); fireEvent.click(screen.getByTestId("group-key-select")); for (const { label, description } of [ { label: "None", description: "Keep all tasks in one list." }, diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx index be92ffcdc3..338a8f45a2 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx @@ -81,7 +81,8 @@ export function SidebarFilterPopover({ {trigger} @@ -103,7 +104,7 @@ export function SidebarFilterPopover({
{editor}
diff --git a/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.test.tsx b/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.test.tsx new file mode 100644 index 0000000000..dfe849054a --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SidebarSettingsDisclosure } from "./sidebar-settings-disclosure"; + +afterEach(cleanup); + +describe("SidebarSettingsDisclosure", () => { + it("toggles uncontrolled content without changing the surrounding draft", () => { + render( + + Content + , + ); + + expect(screen.queryByText("Content")).toBeNull(); + fireEvent.click(screen.getByTestId("settings-toggle")); + expect(screen.getByText("Content")).toBeTruthy(); + expect(screen.getByTestId("settings-toggle").getAttribute("aria-expanded")).toBe("true"); + }); + + it("reports changes while leaving controlled expansion to the parent", () => { + const onExpandedChange = vi.fn(); + render( + + Content + , + ); + + fireEvent.click(screen.getByTestId("settings-toggle")); + expect(onExpandedChange).toHaveBeenCalledWith(true); + expect(screen.queryByText("Content")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.tsx b/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.tsx new file mode 100644 index 0000000000..af8721f620 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useId, useState, type ReactNode } from "react"; +import { IconChevronDown } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { cn } from "@/lib/utils"; + +type Props = { + title: ReactNode; + summary?: ReactNode; + children: ReactNode; + testId: string; + defaultExpanded?: boolean; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; + className?: string; + contentClassName?: string; +}; + +/** Shared compact/mobile-friendly disclosure used by sidebar view settings. */ +export function SidebarSettingsDisclosure({ + title, + summary, + children, + testId, + defaultExpanded = false, + expanded: controlledExpanded, + onExpandedChange, + className, + contentClassName, +}: Props) { + const generatedId = useId(); + const contentId = `${testId}-content-${generatedId.replace(/:/g, "")}`; + const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded); + const expanded = controlledExpanded ?? uncontrolledExpanded; + const setExpanded = (next: boolean) => { + if (controlledExpanded === undefined) setUncontrolledExpanded(next); + onExpandedChange?.(next); + }; + + return ( +
+ + {expanded && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx b/apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx index 40ca660ac1..714d4bea9a 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx @@ -15,8 +15,10 @@ import { cloneSidebarTaskRowPresentation } from "@/lib/state/slices/ui/sidebar-t import { DIMENSION_METAS } from "./filter-dimension-registry"; import { FilterClauseEditor } from "./filter-clause-editor"; import { GroupPicker } from "./group-picker"; -import { SortPicker } from "./sort-picker"; +import { SortPicker, sortKeyLabelKey } from "./sort-picker"; import { TaskRowSettings } from "./task-row-settings"; +import { SidebarSettingsDisclosure } from "./sidebar-settings-disclosure"; +import { AutomaticColorSettings } from "./automatic-color-settings"; import { ViewHeaderRow } from "./view-manager"; export type SidebarViewEditorCurrent = { @@ -46,6 +48,13 @@ export function SidebarViewEditor({ onRemoveClause, }: Props) { const { t } = useTranslation(); + const sortSummary = + current.sort.key === "custom" + ? t(sortKeyLabelKey(current.sort.key)) + : t("task:sortSummary", { + sort: t(sortKeyLabelKey(current.sort.key)), + direction: t("task:sortDirection", { direction: current.sort.direction }), + }); return ( <>
@@ -58,19 +67,30 @@ export function SidebarViewEditor({ onChange={onChangeClause} onRemove={onRemoveClause} /> -
- {t("task:sort")} + onUpdate({ sort })} /> -
-
- {t("task:groupBy")} + + onUpdate({ group })} /> -
+ onUpdate({ taskRow })} /> + ); } @@ -78,19 +98,14 @@ export function SidebarViewEditor({ const SECTION_LABEL_CLASS = "text-[11px] font-medium uppercase leading-none tracking-wide text-muted-foreground"; -function SectionLabel({ - children, - isDrawerLayout, -}: { - children: React.ReactNode; - isDrawerLayout: boolean; -}) { - return ( - - {children} - - ); -} +const GROUP_SUMMARY_KEYS: Record = { + none: "task:groupNone", + repository: "task:groupRepository", + workflow: "task:groupWorkflow", + workflowStep: "task:groupWorkflowStep", + executorType: "task:groupExecutorType", + state: "task:groupState", +}; function FilterSection({ filters, diff --git a/apps/web/components/task/sidebar-filter/sort-picker.tsx b/apps/web/components/task/sidebar-filter/sort-picker.tsx index 9caafcf60d..1322999968 100644 --- a/apps/web/components/task/sidebar-filter/sort-picker.tsx +++ b/apps/web/components/task/sidebar-filter/sort-picker.tsx @@ -40,6 +40,10 @@ const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string; descriptionKey: stri }, ]; +export function sortKeyLabelKey(key: SortKey): string { + return SORT_OPTIONS.find((option) => option.key === key)?.labelKey ?? "task:sortStatus"; +} + type Props = { value: SortSpec; onChange: (next: SortSpec) => void; diff --git a/apps/web/components/task/sidebar-filter/task-color-rule-options.test.tsx b/apps/web/components/task/sidebar-filter/task-color-rule-options.test.tsx new file mode 100644 index 0000000000..f6bcc2b3cf --- /dev/null +++ b/apps/web/components/task/sidebar-filter/task-color-rule-options.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + buildTaskColorRuleOptions, + taskColorDimensionLabelKey, + taskColorRuleOptionKey, +} from "./task-color-rule-options"; + +describe("automatic color rule options", () => { + it("uses workspace-aware values for workflows and steps", () => { + const options = buildTaskColorRuleOptions( + { + workflows: [{ id: "workflow-1", workspaceId: "workspace-1", name: "Delivery" }], + snapshots: { + "workflow-1": { + workflowId: "workflow-1", + workflowName: "Delivery", + steps: [{ id: "step-1", title: "Review", color: "bg-amber-500", position: 0 }], + tasks: [], + }, + }, + executorProfiles: [], + }, + (key) => key, + ); + + expect(options.workflow[0]?.value).toEqual({ + workspace_id: "workspace-1", + workflow_id: "workflow-1", + }); + expect(options.workflow_step[0]?.value).toEqual({ + workspace_id: "workspace-1", + step_id: "step-1", + }); + }); + + it("provides translated scalar options and stable keys", () => { + const options = buildTaskColorRuleOptions( + { workflows: [], snapshots: {}, executorProfiles: [], activeWorkspaceId: null }, + (key) => `translated:${key}`, + ); + expect(options.task_state.find((option) => option.value === "BLOCKED")?.label).toBe( + "translated:common:taskStateBlocked", + ); + expect(options.priority[0]?.key).toBe(taskColorRuleOptionKey("critical")); + expect(options.origin.some((option) => option.value === "kanban")).toBe(true); + }); + + it("uses the localized automatic-color keys for dimensions and origins", () => { + expect([ + taskColorDimensionLabelKey("workflow_step"), + taskColorDimensionLabelKey("repository"), + taskColorDimensionLabelKey("workflow"), + taskColorDimensionLabelKey("executor_profile"), + taskColorDimensionLabelKey("task_state"), + taskColorDimensionLabelKey("priority"), + taskColorDimensionLabelKey("origin"), + ]).toEqual([ + "task:automaticColorsDimensionWorkflowStep", + "task:automaticColorsDimensionRepository", + "task:automaticColorsDimensionWorkflow", + "task:automaticColorsDimensionExecutorProfile", + "task:automaticColorsDimensionTaskState", + "task:automaticColorsDimensionPriority", + "task:automaticColorsDimensionOrigin", + ]); + + const options = buildTaskColorRuleOptions( + { workflows: [], snapshots: {}, executorProfiles: [], activeWorkspaceId: null }, + (key) => `translated:${key}`, + ); + expect(options.origin.map((option) => option.label)).toEqual([ + "translated:task:automaticColorsOriginManual", + "translated:task:automaticColorsOriginAgentCreated", + "translated:task:automaticColorsOriginRoutine", + "translated:task:automaticColorsOriginOnboarding", + "translated:task:automaticColorsOriginAutomationRun", + "translated:task:automaticColorsOriginAutomationTask", + "translated:task:automaticColorsOriginKanban", + ]); + }); + + it("uses executor profile ids instead of agent profile ids", () => { + const options = buildTaskColorRuleOptions( + { + workflows: [], + snapshots: {}, + executorProfiles: [{ id: "executor-profile-1", name: "Docker" }], + }, + (key) => key, + ); + + expect(options.executor_profile).toEqual([ + expect.objectContaining({ value: "executor-profile-1", label: "Docker" }), + ]); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/task-color-rule-options.tsx b/apps/web/components/task/sidebar-filter/task-color-rule-options.tsx new file mode 100644 index 0000000000..0962fb474a --- /dev/null +++ b/apps/web/components/task/sidebar-filter/task-color-rule-options.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; +import type { AppState } from "@/lib/state/store"; +import type { SidebarTaskColorDimension } from "@/lib/task-color-automation-settings"; +import type { ExecutorProfile, TaskOrigin, TaskPriority, TaskState } from "@/lib/types/http"; + +export type TaskColorRuleOption = { + key: string; + value: unknown; + label: string; + secondaryLabel?: string; + color?: string; + available: boolean; +}; + +export type TaskColorRuleOptionMap = Record; + +type Snapshots = AppState["kanbanMulti"]["snapshots"]; +type Workflow = AppState["workflows"]["items"][number]; +type ExecutorProfileOption = Pick; + +const TASK_STATES: readonly TaskState[] = [ + "CREATED", + "SCHEDULING", + "TODO", + "IN_PROGRESS", + "REVIEW", + "BLOCKED", + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", +]; + +const PRIORITIES: readonly TaskPriority[] = ["critical", "high", "medium", "low"]; + +const ORIGINS: readonly { value: TaskOrigin | "kanban"; labelKey: string }[] = [ + { value: "manual", labelKey: "task:automaticColorsOriginManual" }, + { value: "agent_created", labelKey: "task:automaticColorsOriginAgentCreated" }, + { value: "routine", labelKey: "task:automaticColorsOriginRoutine" }, + { value: "onboarding", labelKey: "task:automaticColorsOriginOnboarding" }, + { value: "automation_run", labelKey: "task:automaticColorsOriginAutomationRun" }, + { value: "automation_task", labelKey: "task:automaticColorsOriginAutomationTask" }, + { value: "kanban", labelKey: "task:automaticColorsOriginKanban" }, +]; + +const DIMENSION_LABEL_KEYS: Record = { + workflow_step: "task:automaticColorsDimensionWorkflowStep", + repository: "task:automaticColorsDimensionRepository", + workflow: "task:automaticColorsDimensionWorkflow", + executor_profile: "task:automaticColorsDimensionExecutorProfile", + task_state: "task:automaticColorsDimensionTaskState", + priority: "task:automaticColorsDimensionPriority", + origin: "task:automaticColorsDimensionOrigin", +}; + +const TASK_STATE_LABEL_KEYS: Record = { + CREATED: "common:taskStateCreated", + SCHEDULING: "common:taskStateScheduling", + TODO: "common:taskStateTodo", + IN_PROGRESS: "common:taskStateInProgress", + REVIEW: "common:taskStateReview", + BLOCKED: "common:taskStateBlocked", + WAITING_FOR_INPUT: "common:taskStateWaitingForInput", + COMPLETED: "common:taskStateCompleted", + FAILED: "common:taskStateFailed", + CANCELLED: "common:taskStateCancelled", +}; + +const PRIORITY_LABEL_KEYS: Record = { + critical: "task:priorityCritical", + high: "task:priorityHigh", + medium: "task:priorityMedium", + low: "task:priorityLow", +}; + +export function taskColorRuleOptionKey(value: unknown): string { + return JSON.stringify(value); +} + +export function buildTaskColorRuleOptions( + sources: { + snapshots: Snapshots; + workflows: readonly Workflow[]; + executorProfiles: readonly ExecutorProfileOption[]; + activeWorkspaceId?: string | null; + }, + translate: (key: string) => string, +): TaskColorRuleOptionMap { + const workflowOptions: TaskColorRuleOption[] = []; + const workflowStepOptions: TaskColorRuleOption[] = []; + const workflowsById = new Map(sources.workflows.map((workflow) => [workflow.id, workflow])); + + for (const workflow of sources.workflows) { + workflowOptions.push({ + key: taskColorRuleOptionKey({ workspace_id: workflow.workspaceId, workflow_id: workflow.id }), + value: { workspace_id: workflow.workspaceId, workflow_id: workflow.id }, + label: workflow.name || workflow.id, + available: true, + }); + } + + for (const [snapshotId, snapshot] of Object.entries(sources.snapshots)) { + const workflow = workflowsById.get(snapshotId); + const workspaceId = workflow?.workspaceId ?? sources.activeWorkspaceId ?? undefined; + if (!workspaceId) continue; + if (!workflow) { + workflowOptions.push({ + key: taskColorRuleOptionKey({ + workspace_id: workspaceId, + workflow_id: snapshot.workflowId, + }), + value: { workspace_id: workspaceId, workflow_id: snapshot.workflowId }, + label: snapshot.workflowName || snapshot.workflowId, + available: true, + }); + } + for (const step of [...snapshot.steps].sort((a, b) => a.position - b.position)) { + const value = { workspace_id: workspaceId, step_id: step.id }; + if (workflowStepOptions.some((option) => option.key === taskColorRuleOptionKey(value))) + continue; + workflowStepOptions.push({ + key: taskColorRuleOptionKey(value), + value, + label: step.title || step.id, + secondaryLabel: workflow?.name ?? snapshot.workflowName, + color: step.color, + available: true, + }); + } + } + + workflowOptions.sort((a, b) => a.label.localeCompare(b.label)); + workflowStepOptions.sort((a, b) => + `${a.secondaryLabel ?? ""}/${a.label}`.localeCompare(`${b.secondaryLabel ?? ""}/${b.label}`), + ); + + const executorProfileOptions = sources.executorProfiles + .map((profile) => ({ + key: taskColorRuleOptionKey(profile.id), + value: profile.id, + label: profile.name || profile.id, + available: true, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const taskStateOptions = TASK_STATES.map((value) => ({ + key: taskColorRuleOptionKey(value), + value, + label: translate(TASK_STATE_LABEL_KEYS[value]), + available: true, + })); + const priorityOptions = PRIORITIES.map((value) => ({ + key: taskColorRuleOptionKey(value), + value, + label: translate(PRIORITY_LABEL_KEYS[value]), + available: true, + })); + const originOptions = ORIGINS.map(({ value, labelKey }) => ({ + key: taskColorRuleOptionKey(value), + value, + label: translate(labelKey), + available: true, + })); + + return { + workflow_step: workflowStepOptions, + repository: [], + workflow: workflowOptions, + executor_profile: executorProfileOptions, + task_state: taskStateOptions, + priority: priorityOptions, + origin: originOptions, + }; +} + +export function useTaskColorRuleOptions(): TaskColorRuleOptionMap { + const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const workflows = useAppStore((state) => state.workflows.items); + const executors = useAppStore((state) => state.executors.items); + const executorProfiles = useMemo( + () => executors.flatMap((executor) => executor.profiles ?? []), + [executors], + ); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const { t, i18n } = useTranslation(); + + return useMemo( + () => + buildTaskColorRuleOptions( + { snapshots, workflows, executorProfiles, activeWorkspaceId }, + (key) => t(key), + ), + [activeWorkspaceId, executorProfiles, i18n.language, snapshots, t, workflows], + ); +} + +export function taskColorDimensionLabel( + dimension: SidebarTaskColorDimension, + translate: (key: string) => string, +): string { + return translate(DIMENSION_LABEL_KEYS[dimension]); +} + +export function taskColorDimensionLabelKey(dimension: SidebarTaskColorDimension): string { + return DIMENSION_LABEL_KEYS[dimension]; +} diff --git a/apps/web/components/task/sidebar-filter/task-row-settings.tsx b/apps/web/components/task/sidebar-filter/task-row-settings.tsx index c10d81c708..85b25db199 100644 --- a/apps/web/components/task/sidebar-filter/task-row-settings.tsx +++ b/apps/web/components/task/sidebar-filter/task-row-settings.tsx @@ -19,8 +19,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { IconChevronDown, IconGripVertical } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; +import { IconGripVertical } from "@tabler/icons-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Switch } from "@kandev/ui/switch"; import { useTranslation } from "react-i18next"; @@ -31,6 +30,7 @@ import { type SidebarTaskRowPresentation, type SidebarTaskRowTrailing, } from "@/lib/state/slices/ui/sidebar-task-row-presentation"; +import { SidebarSettingsDisclosure } from "./sidebar-settings-disclosure"; const DETAIL_LABEL_KEYS: Record = { relative_time: "task:taskRowRelativeTime", @@ -308,44 +308,28 @@ export function TaskRowSettings({ onChange: (value: SidebarTaskRowPresentation) => void; }) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); const detailCountLabel = t("task:taskRowDetailsCount", { count: value.visibleDetails.length, }); const trailingLabel = t(TRAILING_LABEL_KEYS[value.trailing]); return ( -
- - - {expanded && ( -
- - -
- )} -
+ } + testId="task-row-settings" + className="border-b" + contentClassName="space-y-2 pt-1" + > + + + ); } diff --git a/apps/web/components/task/sidebar-filter/use-automatic-color-settings-controller.ts b/apps/web/components/task/sidebar-filter/use-automatic-color-settings-controller.ts new file mode 100644 index 0000000000..3a84e9e8f9 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-automatic-color-settings-controller.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; +import { useAppStore } from "@/components/state-provider"; +import { + isRepositoryRuleTarget, + type RepositoryRuleTarget, +} from "@/lib/sidebar/repository-rule-identity"; +import { + MAX_AUTOMATIC_TASK_COLOR_RULES, + type SidebarTaskColorRule, +} from "@/lib/task-color-automation-settings"; +import { useRepositoryRuleCatalog } from "./use-repository-rule-catalog"; +import { useSidebarTaskColorAutomation } from "./use-sidebar-task-color-automation"; +import { useTaskColorRuleOptions } from "./task-color-rule-options"; +import { createAutomaticColorRule } from "./automatic-color-rule-settings-helpers"; + +export function useAutomaticColorSettingsController() { + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const { value, update, saving, error } = useSidebarTaskColorAutomation(); + const scalarOptions = useTaskColorRuleOptions(); + const [expanded, setExpanded] = useState(false); + const [activeRepositoryRuleId, setActiveRepositoryRuleId] = useState(null); + const [repositoryQuery, setRepositoryQuery] = useState(""); + const unavailableTargets = useMemo(() => findUnavailableTargets(value.rules), [value.rules]); + const repositoryCatalog = useRepositoryRuleCatalog(workspaceId, expanded, unavailableTargets); + + useEffect(() => { + if (activeRepositoryRuleId && !value.rules.some((rule) => rule.id === activeRepositoryRuleId)) { + setActiveRepositoryRuleId(null); + } + }, [activeRepositoryRuleId, value.rules]); + + function updateSettings(nextRules: SidebarTaskColorRule[] | undefined, enabled = value.enabled) { + update({ enabled, rules: nextRules ?? value.rules }); + } + + function updateRule(ruleId: string, next: SidebarTaskColorRule) { + updateSettings(value.rules.map((rule) => (rule.id === ruleId ? next : rule))); + } + + function addRule() { + if (value.rules.length >= MAX_AUTOMATIC_TASK_COLOR_RULES) return; + updateSettings([...value.rules, createAutomaticColorRule(value.rules.length + 1)]); + } + + function removeRule(ruleId: string) { + updateSettings(value.rules.filter((rule) => rule.id !== ruleId)); + } + + function reorderRules(activeId: string, overId: string) { + const oldIndex = value.rules.findIndex((rule) => rule.id === activeId); + const newIndex = value.rules.findIndex((rule) => rule.id === overId); + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return; + updateSettings(arrayMove(value.rules, oldIndex, newIndex)); + } + + function setRepositorySearch(query: string) { + setRepositoryQuery(query); + repositoryCatalog.setQuery(query); + } + + function openRepository(ruleId: string) { + setActiveRepositoryRuleId(ruleId); + setRepositorySearch(""); + } + + function closeRepository() { + setActiveRepositoryRuleId(null); + setRepositorySearch(""); + } + + return { + workspaceId, + value, + updateRule, + updateSettings, + addRule, + removeRule, + reorderRules, + saving, + error, + scalarOptions, + expanded, + setExpanded, + activeRepositoryRule: value.rules.find((rule) => rule.id === activeRepositoryRuleId), + repositoryQuery, + setRepositorySearch, + openRepository, + closeRepository, + repositoryCatalog, + }; +} + +export type AutomaticColorSettingsController = ReturnType< + typeof useAutomaticColorSettingsController +>; + +function findUnavailableTargets( + rules: readonly SidebarTaskColorRule[], +): { target: RepositoryRuleTarget; label: string }[] { + return rules.flatMap((rule) => { + if ( + rule.condition.dimension !== "repository" || + !isRepositoryRuleTarget(rule.condition.value) + ) { + return []; + } + return [{ target: rule.condition.value, label: rule.condition.label }]; + }); +} diff --git a/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.test.tsx b/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.test.tsx new file mode 100644 index 0000000000..4293945052 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.test.tsx @@ -0,0 +1,77 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + fetchAccessibleRepos: vi.fn(), + listUserProjects: vi.fn(), + listAzureDevOpsProjects: vi.fn(), + listAzureDevOpsRepositories: vi.fn(), +})); + +vi.mock("@/lib/api/domains/github-api", () => ({ + fetchAccessibleRepos: mocks.fetchAccessibleRepos, +})); +vi.mock("@/lib/api/domains/gitlab-api", () => ({ + listUserProjects: mocks.listUserProjects, +})); +vi.mock("@/lib/api/domains/azure-devops-api", () => ({ + listAzureDevOpsProjects: mocks.listAzureDevOpsProjects, + listAzureDevOpsRepositories: mocks.listAzureDevOpsRepositories, +})); +vi.mock("@/components/task/add-workspace-sources/use-workspace-repository-options", () => ({ + useWorkspaceRepositoryOptions: () => ({ + repositories: [], + discoveredRepositories: [], + repositoriesRefreshing: false, + error: null, + refreshRepositoryOptions: vi.fn(), + }), +})); + +import { pluginRegistry } from "@/lib/plugins/registry"; +import { useRepositoryRuleCatalog } from "./use-repository-rule-catalog"; + +const WORKSPACE_ID = "workspace-1"; +const PLUGIN_ID = "catalog-bitbucket-provider"; + +afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin(PLUGIN_ID); + vi.resetAllMocks(); +}); + +describe("useRepositoryRuleCatalog", () => { + it("keeps provider hosts searchable through the remote hook", async () => { + pluginRegistry.forPlugin(PLUGIN_ID).registerRepositoryProvider({ + id: "bitbucket", + label: "Bitbucket", + matchesURL: () => false, + listBranches: async () => [], + inspectURL: async () => null, + listRepositories: async () => [ + { + providerId: "bitbucket", + providerHost: "https://bitbucket.org", + ownerOrProject: "platform", + repositoryId: "api", + repositoryName: "api", + cloneUrl: "https://bitbucket.org/platform/api.git", + }, + ], + }); + mocks.fetchAccessibleRepos.mockRejectedValue(new Error("GitHub not configured")); + mocks.listUserProjects.mockRejectedValue(new Error("GitLab not configured")); + mocks.listAzureDevOpsProjects.mockRejectedValue(new Error("Azure DevOps not configured")); + + const { result } = renderHook(() => useRepositoryRuleCatalog(WORKSPACE_ID, true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.setQuery("bitbucket.org")); + await waitFor(() => expect(result.current.options).toHaveLength(1)); + expect(result.current.options[0]).toMatchObject({ + label: "platform/api", + secondaryLabel: "https://bitbucket.org", + group: "plugin", + }); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.ts b/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.ts new file mode 100644 index 0000000000..5118a848ac --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-repository-rule-catalog.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRemoteRepositories } from "@/hooks/domains/integrations/use-remote-repositories"; +import { useWorkspaceRepositoryOptions } from "@/components/task/add-workspace-sources/use-workspace-repository-options"; +import type { RepositoryRuleTarget } from "@/lib/sidebar/repository-rule-identity"; +import { + buildRepositoryRuleCatalog, + type RepositoryRuleCatalogOption, +} from "@/lib/sidebar/repository-rule-catalog"; + +export function useRepositoryRuleCatalog( + workspaceId: string | null, + open: boolean, + unavailableTargets: readonly { target: RepositoryRuleTarget; label?: string }[] = [], +) { + const repositoryOptions = useWorkspaceRepositoryOptions(workspaceId, open); + const remoteRepositories = useRemoteRepositories(open && workspaceId ? workspaceId : ""); + const [query, setQuery] = useState(""); + + useEffect(() => { + remoteRepositories.search(query); + }, [query, remoteRepositories.search]); + + const options = useMemo( + () => + buildRepositoryRuleCatalog({ + workspaceRepositories: repositoryOptions.repositories, + localRepositories: repositoryOptions.discoveredRepositories, + remoteRepositories: remoteRepositories.repos, + unavailableTargets, + query, + }), + [ + query, + remoteRepositories.repos, + repositoryOptions.discoveredRepositories, + repositoryOptions.repositories, + unavailableTargets, + ], + ); + + const refresh = useCallback(() => { + repositoryOptions.refreshRepositoryOptions(); + remoteRepositories.refresh?.(); + }, [remoteRepositories, repositoryOptions]); + + return { + options, + query, + setQuery, + loading: repositoryOptions.repositoriesRefreshing || remoteRepositories.loading, + error: repositoryOptions.error ?? remoteRepositories.error, + refresh, + }; +} diff --git a/apps/web/components/task/sidebar-filter/use-sidebar-task-color-automation.ts b/apps/web/components/task/sidebar-filter/use-sidebar-task-color-automation.ts new file mode 100644 index 0000000000..5ca3d2ed0c --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-sidebar-task-color-automation.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync"; +import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings"; +import { useTranslation } from "react-i18next"; + +export function useSidebarTaskColorAutomation() { + const store = useAppStoreApi(); + const value = useAppStore((state) => state.userSettings.sidebarTaskColorAutomation); + const { t } = useTranslation(); + const confirmedRef = useRef(value); + const localRef = useRef(value); + const operationRef = useRef(0); + const pendingRef = useRef(0); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const sync = useMemo( + () => + createQueuedUserSettingsSyncWithResponse( + (next): UserSettingsUpdatePayload => ({ sidebar_task_color_automation: next }), + ), + [], + ); + + useEffect(() => { + if (pendingRef.current === 0 && value !== localRef.current) { + confirmedRef.current = value; + localRef.current = value; + } + }, [value]); + + const update = useCallback( + (next: SidebarTaskColorAutomation) => { + const operation = ++operationRef.current; + pendingRef.current += 1; + setSaving(true); + setError(null); + localRef.current = next; + const current = store.getState().userSettings; + store.getState().setUserSettings({ + ...current, + sidebarTaskColorAutomation: next, + loaded: true, + }); + + void sync(next) + .then((response) => { + const mapped = mapUserSettingsResponse(response, store.getState().userSettings); + if (operation === operationRef.current) { + confirmedRef.current = mapped.sidebarTaskColorAutomation; + localRef.current = mapped.sidebarTaskColorAutomation; + store.getState().setUserSettings(mapped); + } + }) + .catch(() => { + if (operation !== operationRef.current) return; + const rollback = confirmedRef.current; + localRef.current = rollback; + const currentSettings = store.getState().userSettings; + store.getState().setUserSettings({ + ...currentSettings, + sidebarTaskColorAutomation: rollback, + loaded: true, + }); + setError(t("task:automaticColorsSaveError")); + }) + .finally(() => { + pendingRef.current -= 1; + if (operation === operationRef.current) setSaving(pendingRef.current > 0); + }); + }, + [store, sync, t], + ); + + return { value, update, saving, error }; +} + +export type SidebarTaskColorSettingsSync = ReturnType; diff --git a/apps/web/components/task/task-archived-context.tsx b/apps/web/components/task/task-archived-context.tsx index e932d40d84..3acae55b7e 100644 --- a/apps/web/components/task/task-archived-context.tsx +++ b/apps/web/components/task/task-archived-context.tsx @@ -3,9 +3,11 @@ import { createContext, useContext, memo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useTranslation } from "react-i18next"; +import type { Task } from "@/lib/types/http"; type TaskArchivedState = { isArchived: boolean; + archivedTask?: Task; archivedTaskId?: string; archivedTaskTitle?: string; /** diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 44c05d6088..8aa85f28d1 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -6,7 +6,12 @@ import { cn } from "@/lib/utils"; import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent"; import { TaskItemStatsRow } from "./task-item-stats-row"; import { useTaskColor } from "@/hooks/use-task-color"; -import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; +import { + manualTaskColorPresentation, + resolveTaskItemColor, + type TaskMarkerPresentation, +} from "@/lib/task-color-presentation"; +import type { TaskColor } from "@/lib/task-colors"; import type { ForegroundActivity, TaskPriority, @@ -112,6 +117,7 @@ type TaskItemProps = { issueInfo?: { url: string; number: number }; isPinned?: boolean; agentErrorMessage?: string | null; + automaticColor?: TaskMarkerPresentation; taskRowPresentation?: import("@/lib/state/slices/ui/sidebar-task-row-presentation").SidebarTaskRowPresentation; }; @@ -359,6 +365,7 @@ export const TaskItem = memo(function TaskItem({ issueInfo, isPinned, agentErrorMessage, + automaticColor, isOnLastWorkflowStep = false, taskRowPresentation, }: TaskItemProps) { @@ -366,6 +373,7 @@ export const TaskItem = memo(function TaskItem({ const resolvedTaskRow = resolveTaskRowPresentation(taskRowPresentation, { showRepository }); const relativeTime = showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt; const taskColor = useTaskColor(taskId); + const manualColor = manualTaskColorPresentation(taskColor); const indent = computeRowIndent(resolveRowDepth(depth, isSubTask)); return ( @@ -388,7 +396,10 @@ export const TaskItem = memo(function TaskItem({ archiveConfirmation && "flex-wrap", )} > - + ); } diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index c0072e9730..48d0169d37 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -177,6 +177,7 @@ export function buildArchivedValue(task: Task | null, repository: Repository | n const isArchived = !!task?.archived_at; return { isArchived, + archivedTask: isArchived ? (task ?? undefined) : undefined, archivedTaskId: isArchived ? task?.id : undefined, archivedTaskTitle: isArchived ? task?.title : undefined, archivedTaskRepositoryLabel: isArchived && repository ? repositorySlug(repository) : undefined, diff --git a/apps/web/components/task/task-session-sidebar-archived-item.test.ts b/apps/web/components/task/task-session-sidebar-archived-item.test.ts new file mode 100644 index 0000000000..244d9a7bb5 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-archived-item.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { Repository, Task } from "@/lib/types/http"; +import { buildArchivedValue } from "./task-page-content-helpers"; +import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item"; + +describe("buildArchivedSidebarItem", () => { + it("resolves automatic colors from the archived task facts", () => { + const task = { + id: "task-1", + workspace_id: "workspace-1", + workflow_id: "workflow-1", + workflow_step_id: "step-1", + position: 0, + title: "Archived task", + description: "", + state: "TODO", + priority: "medium", + archived_at: "2026-08-01T00:00:00Z", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", + repositories: [{ id: "link-1", repository_id: "repo-1", base_branch: "main", position: 0 }], + } as unknown as Task; + const repository = { + id: "repo-1", + workspace_id: "workspace-1", + name: "web", + provider: "", + provider_repo_id: "", + provider_owner: "", + provider_name: "", + local_path: "/work/web", + } as unknown as Repository; + const value = buildArchivedValue(task, repository); + + const item = buildArchivedSidebarItem(value, { + repositorySlugById: new Map([["repo-1", "web"]]), + titleById: new Map(), + workflowNameById: new Map([["workflow-1", "Delivery"]]), + stepTitleById: new Map([["step-1", "Todo"]]), + repositoriesById: new Map([["repo-1", repository]]), + stepColorById: new Map([["step-1", "blue"]]), + automaticColorSettings: { + enabled: true, + rules: [ + { + id: "todo-rule", + enabled: true, + condition: { dimension: "task_state", value: "TODO", label: "Todo" }, + output: { kind: "fixed", color: "cyan" }, + }, + ], + }, + }); + + expect(item.isArchived).toBe(true); + expect(item.workflowId).toBe("workflow-1"); + expect(item.state).toBe("TODO"); + expect(item.automaticColor).toEqual({ token: "cyan", className: "bg-cyan-500" }); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-archived-item.ts b/apps/web/components/task/task-session-sidebar-archived-item.ts index bec8620f21..3f50ab393e 100644 --- a/apps/web/components/task/task-session-sidebar-archived-item.ts +++ b/apps/web/components/task/task-session-sidebar-archived-item.ts @@ -1,10 +1,17 @@ import type { TaskSwitcherItem } from "./task-switcher"; import type { useArchivedTaskState } from "./task-archived-context"; import { t } from "@/lib/i18n"; +import { toKanbanTask } from "@/lib/kanban/map-task"; +import { buildSidebarItem, type SidebarItemContext } from "./task-session-sidebar-item"; export function buildArchivedSidebarItem( s: ReturnType, + context?: SidebarItemContext, ): TaskSwitcherItem { + if (s.archivedTask && context) { + const task = toKanbanTask(s.archivedTask); + return buildSidebarItem({ ...task, _workflowId: task.workflowId }, context); + } return { id: s.archivedTaskId!, title: s.archivedTaskTitle ?? t("task:archivedTask"), diff --git a/apps/web/components/task/task-session-sidebar-item.ts b/apps/web/components/task/task-session-sidebar-item.ts index 99414b7584..0d3863e3f0 100644 --- a/apps/web/components/task/task-session-sidebar-item.ts +++ b/apps/web/components/task/task-session-sidebar-item.ts @@ -1,13 +1,16 @@ import type { KanbanState } from "@/lib/state/slices"; -import type { TaskSessionState, TaskState } from "@/lib/types/http"; +import type { Repository, TaskSessionState, TaskState } from "@/lib/types/http"; import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; import { workflowStepTitle } from "./task-session-sidebar-aggregate"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; import { resolveTaskRepositorySlugs } from "@/lib/sidebar/sidebar-task-repositories"; import { taskPRInfoFromSummary } from "./task-pr-info"; +import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import { taskColorFacts } from "@/lib/sidebar/task-color-projection"; +import { resolveAutomaticTaskColor } from "@/lib/sidebar/task-color-rules"; -type SidebarItemContext = { +export type SidebarItemContext = { repositorySlugById: Map; titleById: Map; workflowNameById: Map; @@ -15,8 +18,15 @@ type SidebarItemContext = { wipQueueByTaskId?: Map; acknowledgedAgentErrors?: Record; dismissedAgentErrors?: Record; + workspaceId?: string; + repositoriesById?: ReadonlyMap; + stepColorById?: ReadonlyMap; + automaticColorSettings?: SidebarTaskColorAutomation; }; +const EMPTY_REPOSITORIES_BY_ID = new Map(); +const EMPTY_STEP_COLORS = new Map(); + function summaryDiffStats( summary: TaskStatusSummary | null | undefined, ): { additions: number; deletions: number } | undefined { @@ -116,6 +126,14 @@ export function buildSidebarItem( context: SidebarItemContext, ) { const status = sidebarStatus(task, context); + const facts = taskColorFacts(task, { + workspaceId: context.workspaceId, + repositoriesById: context.repositoriesById ?? EMPTY_REPOSITORIES_BY_ID, + stepColorById: context.stepColorById ?? EMPTY_STEP_COLORS, + }); + const automaticColor = context.automaticColorSettings + ? resolveAutomaticTaskColor(context.automaticColorSettings, facts) + : null; return { id: task.id, @@ -130,6 +148,10 @@ export function buildSidebarItem( workflowName: context.workflowNameById.get(task._workflowId), workflowStepId: task.workflowStepId as string | undefined, workflowStepTitle: workflowStepTitle(task, context.stepTitleById), + workspaceId: facts.workspaceId, + origin: task.origin, + primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined, + workflowStepColor: facts.workflowStepColor, isRemoteExecutor: task.isRemoteExecutor, remoteExecutorId: task.primaryExecutorId ?? undefined, remoteExecutorType: task.primaryExecutorType ?? undefined, @@ -141,6 +163,9 @@ export function buildSidebarItem( workspaceMode: task.workspaceMode, repositories: resolveTaskRepositorySlugs(task.repositories, context.repositorySlugById), repositoryLinks: task.repositories, + repositoryRuleIdentities: facts.repositories, + automaticColor: automaticColor?.color, + automaticColorSource: automaticColor?.source, isPRReview: task.isPRReview ?? false, isIssueWatch: task.isIssueWatch ?? false, }; diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 1fc7d3b5ac..57a05121ee 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -67,6 +67,9 @@ function useSidebarData(workspaceId: string | null) { const acknowledgedAgentErrors = useAppStore((state) => state.acknowledgedAgentErrors); const dismissedAgentErrors = useAppStore((state) => state.dismissedAgentErrors); const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const automaticColorSettings = useAppStore( + (state) => state.userSettings.sidebarTaskColorAutomation, + ); const archivedState = useArchivedTaskState(); const selectedTaskId = useMemo(() => { @@ -90,6 +93,12 @@ function useSidebarData(workspaceId: string | null) { const repositorySlugById = new Map( repositories.map((repo: Repository) => [repo.id, repositorySlug(repo)]), ); + const repositoriesById = new Map( + Object.values(repositoriesByWorkspace) + .flat() + .map((repo: Repository) => [repo.id, repo]), + ); + const stepColorById = new Map(allSteps.map((step) => [step.id, step.color])); const titleById = new Map(allTasks.map((t) => [t.id, t.title])); const workflowNameById = new Map(workflows.map((w) => [w.id, w.name])); const stepTitleById = new Map(allSteps.map((s) => [s.id, s.title])); @@ -101,6 +110,10 @@ function useSidebarData(workspaceId: string | null) { wipQueueByTaskId, acknowledgedAgentErrors, dismissedAgentErrors, + workspaceId: workspaceId ?? undefined, + repositoriesById, + stepColorById, + automaticColorSettings, }; const items: TaskSwitcherItem[] = allTasks.map((task) => buildSidebarItem(task, mapCtx)); if ( @@ -108,7 +121,7 @@ function useSidebarData(workspaceId: string | null) { archivedState.archivedTaskId && !items.some((t) => t.id === archivedState.archivedTaskId) ) { - items.unshift(buildArchivedSidebarItem(archivedState)); + items.unshift(buildArchivedSidebarItem(archivedState, mapCtx)); } return items; }, [ @@ -121,6 +134,7 @@ function useSidebarData(workspaceId: string | null) { wipQueueByTaskId, acknowledgedAgentErrors, dismissedAgentErrors, + automaticColorSettings, ]); return { diff --git a/apps/web/components/task/task-switcher-color-menu.tsx b/apps/web/components/task/task-switcher-color-menu.tsx index 2875985a2e..b2bf4f59c9 100644 --- a/apps/web/components/task/task-switcher-color-menu.tsx +++ b/apps/web/components/task/task-switcher-color-menu.tsx @@ -17,8 +17,17 @@ import { } from "@/lib/task-colors"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import type { AutomaticTaskColorSource } from "@/lib/sidebar/task-color-rules"; -export function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) { +export function TaskColorMenu({ + taskId, + disabled, + automaticColorSource, +}: { + taskId: string; + disabled?: boolean; + automaticColorSource?: AutomaticTaskColorSource; +}) { const { t } = useTranslation(); const currentColor = useTaskColor(taskId); const setColor = useSetTaskColor(); @@ -37,6 +46,14 @@ export function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: )} + {automaticColorSource && ( + <> + + {t("task:automaticColorSource", { rule: automaticColorSource.label })} + + + + )} {TASK_COLORS.map((color) => ( - {!task.isArchived && } + {!task.isArchived && ( + + )} ; diffStats?: { additions: number; deletions: number }; diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 6a42407c70..38412572d2 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -625,6 +625,7 @@ test.beforeEach(async ({ apiClient, backend, seedData }) => { agent_profile_id: seedData.agentProfileId, workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId }, }, + sidebar_task_color_automation: { enabled: false, rules: [] }, }); }); }); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index afc3f4907a..64f41aacc2 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -12,8 +12,10 @@ import type { WorkflowProfileSessionStartPolicy, WorkflowProfileSessionEndPolicy, TaskPriority, + SidebarTaskColorPatchApi, } from "../../lib/types/http"; import type { Agent, AgentProfile, AvailableAgent } from "../../lib/types/http-agents"; +import type { SidebarTaskColorAutomation } from "../../lib/task-color-automation-settings"; import { normalizeAgentProfile } from "../../lib/api/domains/agent-profile-normalize"; import type { PRCommitDetail, @@ -1203,6 +1205,8 @@ export class ApiClient { task_create_last_used?: TaskCreateLastUsedApi; kanban_hidden_step_ids?: Record; workflow_ids_with_auto_hide_empty_steps?: string[]; + sidebar_task_color_automation?: SidebarTaskColorAutomation; + sidebar_task_color_patch?: SidebarTaskColorPatchApi; }): Promise { await this.request("PATCH", "/api/v1/user/settings", settings); } diff --git a/apps/web/e2e/pages/sidebar-filter-popover.ts b/apps/web/e2e/pages/sidebar-filter-popover.ts index 6aad58489c..b7d04113f3 100644 --- a/apps/web/e2e/pages/sidebar-filter-popover.ts +++ b/apps/web/e2e/pages/sidebar-filter-popover.ts @@ -167,6 +167,7 @@ export class SidebarFilterPopoverPage { } async setSort(keyLabel: string, direction?: "asc" | "desc"): Promise { + await this.openSortSettings(); const keyTrigger = this.popover.getByTestId("sort-key-select"); await keyTrigger.click(); await this.page.getByRole("option", { name: keyLabel, exact: true }).click(); @@ -178,11 +179,24 @@ export class SidebarFilterPopoverPage { } async setGroup(groupLabel: string): Promise { + await this.openGroupSettings(); const trigger = this.popover.getByTestId("group-key-select"); await trigger.click(); await this.page.getByRole("option", { name: groupLabel, exact: true }).click(); } + async openSortSettings(): Promise { + const toggle = this.popover.getByTestId("sidebar-sort-settings-toggle"); + if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click(); + await expect(this.popover.getByTestId("sort-key-select")).toBeVisible(); + } + + async openGroupSettings(): Promise { + const toggle = this.popover.getByTestId("sidebar-group-settings-toggle"); + if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click(); + await expect(this.popover.getByTestId("group-key-select")).toBeVisible(); + } + get taskRowSettings(): Locator { return this.popover.getByTestId("task-row-settings"); } diff --git a/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts new file mode 100644 index 0000000000..331623938d --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import type { SidebarTaskColorAutomation } from "../../../lib/task-color-automation-settings"; + +const MOBILE_REPOSITORY_RULE_ID = "mobile-repository-rule"; + +test.describe("Mobile sidebar automatic task colors", () => { + test("keeps repository selection in the drawer and applies the stored rule", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + await apiClient.createTask(seedData.workspaceId, "Mobile automatic color task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const navTask = await apiClient.seedTask(seedData.workspaceId, "Automatic colors mobile nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const automation: SidebarTaskColorAutomation = { + enabled: true, + rules: [ + { + id: MOBILE_REPOSITORY_RULE_ID, + enabled: true, + condition: { + dimension: "repository", + value: { + kind: "local", + path: seedData.repositoryPath, + }, + label: "E2E Repo", + }, + output: { kind: "fixed", color: "purple" }, + }, + ], + }; + await apiClient.saveUserSettings({ sidebar_task_color_automation: automation }); + + await testPage.goto(`/t/${navTask.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").click(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + await expect(sheet.getByTestId("sidebar-filter-bar")).toBeVisible(); + + const row = sheet + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile automatic color task" }) + .first(); + await expect(row).toBeVisible(); + await expect(row.getByTestId("task-item-color-marker")).toHaveAttribute( + "data-color-token", + "purple", + ); + + await sheet.getByTestId("sidebar-filter-gear").tap(); + const popover = testPage.getByTestId("sidebar-filter-popover"); + await expect(popover).toBeVisible(); + const automaticSettings = popover.getByTestId("automatic-color-settings"); + await automaticSettings.getByTestId("automatic-color-settings-toggle").tap(); + await expect(automaticSettings.getByText("Rule 1 Repository", { exact: true })).toBeVisible(); + await expect(automaticSettings.getByTestId("automatic-colors-timing")).toHaveCount(0); + await expect(automaticSettings.getByTestId("automatic-colors-help")).toHaveCount(1); + await expect( + automaticSettings.getByText( + "Personal setting. Applies across sidebar views and workspaces.", + { exact: true }, + ), + ).toHaveCount(0); + await automaticSettings.getByTestId("automatic-colors-help").focus(); + const timingTooltip = testPage.getByRole("tooltip"); + await expect(timingTooltip).toContainText("Rules apply to existing and new sidebar tasks."); + + const sectionPadding = await Promise.all( + ["sidebar-sort-settings", "sidebar-group-settings", "task-row-settings"].map((testId) => + popover.getByTestId(testId).evaluate((element) => { + const styles = getComputedStyle(element); + return { paddingBottom: styles.paddingBottom, paddingTop: styles.paddingTop }; + }), + ), + ); + expect(sectionPadding).toEqual([ + { paddingBottom: "4px", paddingTop: "4px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + ]); + + const repositoryTrigger = automaticSettings.getByTestId( + `automatic-color-repository-trigger-${MOBILE_REPOSITORY_RULE_ID}`, + ); + await expect(repositoryTrigger).toBeVisible(); + await repositoryTrigger.tap(); + const repositoryPane = automaticSettings.getByTestId("automatic-color-repository-pane"); + await expect(repositoryPane).toBeVisible(); + const repositoryOption = repositoryPane.getByRole("button", { name: "E2E Repo", exact: false }); + await expect(repositoryOption).toBeVisible(); + await expect(testPage.getByTestId("sidebar-filter-drawer")).toContainText("E2E Repo"); + await prCapture.screenshot("mobile-automatic-task-colors", { + caption: "Mobile repository target picker for automatic task colors", + }); + + await repositoryOption.tap(); + await expect(repositoryPane).toBeHidden(); + + await testPage.reload(); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").click(); + const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); + await expect(reloadedSheet.getByTestId("sidebar-filter-bar")).toBeVisible(); + await expect( + reloadedSheet + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile automatic color task" }) + .getByTestId("task-item-color-marker"), + ).toHaveAttribute("data-color-token", "purple"); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.sidebar_task_color_automation) + .toEqual(automation); + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index 7a79f28d0c..2443c5ecbd 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -226,9 +226,12 @@ test.describe("Mobile sidebar task actions", () => { workflow_step_id: seedData.startStepId, }); - await testPage.addInitScript((taskId) => { - window.localStorage.setItem("kandev.taskColors", JSON.stringify({ [taskId]: "red" })); - }, task.id); + await apiClient.saveUserSettings({ + sidebar_task_color_patch: { + colors: { [task.id]: "red" }, + if_missing: false, + }, + }); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts new file mode 100644 index 0000000000..6fa0e5815d --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Mobile sidebar personal task colors", () => { + test("displays the server-backed manual color in the drawer after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Mobile manual color task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const navTask = await apiClient.seedTask(seedData.workspaceId, "Manual colors mobile nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.saveUserSettings({ + sidebar_task_color_patch: { + colors: { [task.task_id]: "purple" }, + if_missing: false, + }, + }); + + await testPage.goto(`/t/${navTask.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.mobileSessionMenu.tap(); + + const drawer = testPage + .getByTestId("mobile-task-switcher-list") + .locator("xpath=ancestor::*[@data-slot='drawer-content'][1]"); + await expect(drawer).toBeVisible(); + const marker = drawer + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile manual color task" }) + .getByTestId("task-item-color-marker"); + await expect(marker).toHaveAttribute("data-color-token", "purple"); + + await testPage.reload(); + await session.waitForLoad(); + await session.mobileSessionMenu.tap(); + const reloadedDrawer = testPage + .getByTestId("mobile-task-switcher-list") + .locator("xpath=ancestor::*[@data-slot='drawer-content'][1]"); + await expect(reloadedDrawer).toBeVisible(); + await expect( + reloadedDrawer + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile manual color task" }) + .getByTestId("task-item-color-marker"), + ).toHaveAttribute("data-color-token", "purple"); + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts index b1d5a46605..ea50c099ca 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts @@ -382,6 +382,7 @@ test.describe("Mobile sidebar — view system", () => { await gear.tap(); const popover = testPage.getByTestId("sidebar-filter-popover"); await expect(popover).toBeVisible(); + await popover.getByTestId("sidebar-sort-settings-toggle").tap(); await popover.getByTestId("sort-key-select").tap(); await expect(testPage.getByRole("option", { name: "Updated", exact: true })).toContainText( "Last task summary refresh. Background events can change it.", @@ -530,6 +531,7 @@ test.describe("Mobile sidebar — view system", () => { const popover = testPage.getByTestId("sidebar-filter-popover"); await expect(drawer).toBeVisible(); await expect(popover).toBeVisible(); + await popover.getByTestId("sidebar-group-settings-toggle").tap(); await popover.getByTestId("group-key-select").tap(); for (const { label, description } of [ { label: "None", description: "Keep all tasks in one list." }, diff --git a/apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts b/apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts new file mode 100644 index 0000000000..3cc5b486e0 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts @@ -0,0 +1,244 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { SidebarFilterPopoverPage } from "../../pages/sidebar-filter-popover"; +import type { SidebarTaskColorAutomation } from "../../../lib/task-color-automation-settings"; + +const DESKTOP_AUTOMATION: SidebarTaskColorAutomation = { + enabled: true, + rules: [ + { + id: "desktop-failed-rule", + enabled: true, + condition: { dimension: "task_state", value: "FAILED", label: "Failed" }, + output: { kind: "fixed", color: "red" }, + }, + { + id: "desktop-review-rule", + enabled: true, + condition: { dimension: "task_state", value: "REVIEW", label: "Review" }, + output: { kind: "fixed", color: "blue" }, + }, + ], +}; + +function desktopRepositoryAutomation(repositoryPath: string): SidebarTaskColorAutomation { + return { + enabled: true, + rules: [ + { + id: "desktop-repository-rule", + enabled: false, + condition: { + dimension: "repository", + value: { kind: "local", path: repositoryPath }, + label: "E2E repository", + }, + output: { kind: "fixed", color: "gray" }, + }, + ], + }; +} + +async function openDesktopTask( + testPage: import("@playwright/test").Page, + apiClient: import("../../helpers/api-client").ApiClient, + seedData: import("../../fixtures/test-base").SeedData, + title: string, + state: string, +) { + const task = await apiClient.seedTask(seedData.workspaceId, title, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + state, + }); + const navTask = await apiClient.seedTask(seedData.workspaceId, "Automatic colors desktop nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${navTask.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + return { task, session }; +} + +function sidebarTaskRow(session: SessionPage, title: string) { + return session.sidebar.getByTestId("sidebar-task-item").filter({ hasText: title }).first(); +} + +test.describe("Sidebar automatic task colors", () => { + test("persists ordered rules and recolors a task when its state changes", async ({ + testPage, + apiClient, + seedData, + prCapture, + }) => { + await apiClient.saveUserSettings({ sidebar_task_color_automation: DESKTOP_AUTOMATION }); + const { task, session } = await openDesktopTask( + testPage, + apiClient, + seedData, + "Desktop automatic color task", + "FAILED", + ); + const row = sidebarTaskRow(session, "Desktop automatic color task"); + const marker = row.getByTestId("task-item-color-marker"); + + await expect(row).toBeVisible(); + await expect(marker).toHaveAttribute("data-color-token", "red"); + await expect(marker).toHaveClass(/bg-red-500/); + + const filters = new SidebarFilterPopoverPage(testPage); + await filters.open(); + const disclosureSections = [ + filters.popover.getByTestId("sidebar-sort-settings"), + filters.popover.getByTestId("sidebar-group-settings"), + filters.popover.getByTestId("task-row-settings"), + ]; + const disclosureBottomInsets: number[] = []; + for (const section of disclosureSections) { + await expect(section).toHaveCSS("border-bottom-width", "1px"); + disclosureBottomInsets.push( + await section.evaluate((element) => { + const toggle = element.querySelector("button"); + if (!toggle) throw new Error("Disclosure toggle not found"); + return element.getBoundingClientRect().bottom - toggle.getBoundingClientRect().bottom; + }), + ); + } + expect( + Math.max(...disclosureBottomInsets) - Math.min(...disclosureBottomInsets), + ).toBeLessThanOrEqual(1); + + const automaticSettings = filters.popover.getByTestId("automatic-color-settings"); + await expect(automaticSettings).toBeVisible(); + await automaticSettings.getByTestId("automatic-color-settings-toggle").click(); + await expect(automaticSettings.getByTestId("automatic-color-enabled")).toHaveAttribute( + "data-state", + "checked", + ); + await expect(automaticSettings.getByText("Rule 1 Task state", { exact: true })).toBeVisible(); + await expect(automaticSettings.getByTestId("automatic-colors-timing")).toHaveCount(0); + await expect( + automaticSettings.getByText( + "Personal setting. Applies across sidebar views and workspaces.", + { exact: true }, + ), + ).toHaveCount(0); + await expect( + automaticSettings.getByText( + "The first matching rule wins. Manual colors remain as fallback.", + { + exact: true, + }, + ), + ).toHaveCount(0); + + const dimension = automaticSettings.getByTestId( + "automatic-color-dimension-desktop-failed-rule", + ); + await dimension.click(); + for (const label of [ + "Workflow step", + "Repository", + "Workflow", + "Executor profile", + "Task state", + "Priority", + "Origin", + ]) { + await expect(testPage.getByRole("option", { name: label, exact: true })).toBeVisible(); + } + await testPage.keyboard.press("Escape"); + + const output = automaticSettings.getByTestId("automatic-color-output-desktop-failed-rule"); + await expect(output.locator("span.inline-block.rounded-full")).toHaveCount(1); + + const timingHelp = automaticSettings.getByTestId("automatic-colors-help"); + await timingHelp.hover(); + const timingTooltip = testPage.getByRole("tooltip"); + await expect(timingTooltip).toContainText( + "Personal setting. Applies across sidebar views and workspaces.", + ); + await expect(timingTooltip).toContainText("Rules apply to existing and new sidebar tasks."); + await expect(timingTooltip).toContainText( + "The first matching rule wins. Manual colors remain as fallback.", + ); + await expect(timingTooltip).toContainText( + "Rules run when tasks appear and whenever their workflow step", + ); + + const scrollMetrics = await filters.popover.evaluate((element) => ({ + clientHeight: element.clientHeight, + overflowY: getComputedStyle(element).overflowY, + scrollHeight: element.scrollHeight, + })); + expect(scrollMetrics.overflowY).toBe("auto"); + expect(scrollMetrics.scrollHeight).toBeGreaterThan(scrollMetrics.clientHeight); + await filters.popover.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(automaticSettings.getByTestId("automatic-color-add-rule")).toBeVisible(); + await prCapture.screenshot("desktop-automatic-task-colors", { + caption: "Desktop automatic task color rules", + }); + await testPage.mouse.move(0, 0); + await expect(timingTooltip).toBeHidden(); + await filters.close(); + + await apiClient.updateTaskState(task.task_id, "REVIEW"); + await expect(marker).toHaveAttribute("data-color-token", "blue"); + await expect(marker).toHaveClass(/bg-blue-500/); + + await testPage.reload(); + await session.waitForLoad(); + const reloadedMarker = sidebarTaskRow(session, "Desktop automatic color task").getByTestId( + "task-item-color-marker", + ); + await expect(reloadedMarker).toHaveAttribute("data-color-token", "blue"); + await expect + .poll(async () => { + const { settings } = await apiClient.getUserSettings(); + return settings.sidebar_task_color_automation; + }) + .toEqual(DESKTOP_AUTOMATION); + }); + + test("aligns a repository target with the condition control", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.saveUserSettings({ + sidebar_task_color_automation: desktopRepositoryAutomation(seedData.repositoryPath), + }); + await openDesktopTask( + testPage, + apiClient, + seedData, + "Desktop repository alignment task", + "TODO", + ); + + const filters = new SidebarFilterPopoverPage(testPage); + await filters.open(); + const automaticSettings = filters.popover.getByTestId("automatic-color-settings"); + await automaticSettings.getByTestId("automatic-color-settings-toggle").click(); + + const condition = automaticSettings.getByTestId( + "automatic-color-dimension-desktop-repository-rule", + ); + const repository = automaticSettings.getByTestId( + "automatic-color-repository-trigger-desktop-repository-rule", + ); + await expect(condition).toBeVisible(); + await expect(repository).toBeVisible(); + + const [conditionBox, repositoryBox] = await Promise.all([ + condition.boundingBox(), + repository.boundingBox(), + ]); + expect(conditionBox).not.toBeNull(); + expect(repositoryBox).not.toBeNull(); + expect(Math.abs((repositoryBox?.y ?? 0) - (conditionBox?.y ?? 0))).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/web/e2e/tests/task/sidebar-filter.spec.ts b/apps/web/e2e/tests/task/sidebar-filter.spec.ts index abb106ccfc..0f19667caf 100644 --- a/apps/web/e2e/tests/task/sidebar-filter.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-filter.spec.ts @@ -302,6 +302,7 @@ test.describe("Sidebar filter — group + sort", () => { test("Group by none hides group headers", async ({ testPage, apiClient, seedData }) => { const { session, filters } = await openWithSeed(testPage, apiClient, seedData, ["One", "Two"]); await filters.open(); + await filters.openGroupSettings(); await filters.popover.getByTestId("group-key-select").click(); for (const { label, description } of [ { label: "None", description: "Keep all tasks in one list." }, @@ -333,6 +334,7 @@ test.describe("Sidebar filter — group + sort", () => { test("Sort direction toggle flips icon direction", async ({ testPage, apiClient, seedData }) => { const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Sort A"]); await filters.open(); + await filters.openSortSettings(); const toggle = filters.popover.getByTestId("sort-direction-toggle"); const initial = await toggle.getAttribute("data-direction"); await toggle.click(); @@ -364,6 +366,7 @@ test.describe("Sidebar filter — group + sort", () => { await session.waitForLoad(); const filters = new SidebarFilterPopoverPage(testPage); await filters.open(); + await filters.openSortSettings(); await filters.popover.getByTestId("sort-key-select").click(); await expect(testPage.getByRole("option", { name: "Updated", exact: true })).toContainText( "Last task summary refresh. Background events can change it.", @@ -500,11 +503,15 @@ test.describe("Sidebar filter — saved views CRUD", () => { const renameInput = await filters.beginNewView(); await expect(renameInput).toHaveValue("New view"); await expect(filters.popover.getByTestId("filter-clause-row")).toHaveCount(0); + await expect(filters.popover.getByTestId("sort-key-select")).toHaveCount(0); + await expect(filters.popover.getByTestId("group-key-select")).toHaveCount(0); + await filters.openSortSettings(); await expect(filters.popover.getByTestId("sort-key-select")).toContainText("Status"); await expect(filters.popover.getByTestId("sort-direction-toggle")).toHaveAttribute( "data-direction", "asc", ); + await filters.openGroupSettings(); await expect(filters.popover.getByTestId("group-key-select")).toContainText("Repository"); await renameInput.fill("Planning view"); diff --git a/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts b/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts new file mode 100644 index 0000000000..7bc512a46d --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { SidebarTasksPage } from "../../pages/sidebar-tasks-page"; + +test.describe("Sidebar personal task colors", () => { + test("imports legacy colors on direct task startup and preserves existing colors", async ({ + testPage, + apiClient, + seedData, + }) => { + const existingTask = await apiClient.seedTask(seedData.workspaceId, "Legacy existing color", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const importedTask = await apiClient.seedTask(seedData.workspaceId, "Legacy imported color", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const navTask = await apiClient.seedTask(seedData.workspaceId, "Legacy colors startup nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await apiClient.saveUserSettings({ + sidebar_task_color_patch: { + colors: { [existingTask.task_id]: "blue" }, + if_missing: false, + }, + }); + await testPage.addInitScript( + ({ existingTaskId, importedTaskId }) => { + window.localStorage.setItem( + "kandev.taskColors", + JSON.stringify({ [existingTaskId]: "pink", [importedTaskId]: "green" }), + ); + }, + { existingTaskId: existingTask.task_id, importedTaskId: importedTask.task_id }, + ); + + await testPage.goto(`/t/${navTask.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const sidebar = new SidebarTasksPage(testPage); + await expect(sidebar.row(importedTask.task_id)).toBeVisible(); + await expect + .poll(async () => { + const { settings } = await apiClient.getUserSettings(); + const colors = settings.sidebar_task_colors as Record | undefined; + return colors?.[importedTask.task_id]; + }) + .toBe("green"); + await expect + .poll(async () => { + const { settings } = await apiClient.getUserSettings(); + const colors = settings.sidebar_task_colors as Record | undefined; + return colors?.[existingTask.task_id]; + }) + .toBe("blue"); + await expect + .poll(async () => testPage.evaluate(() => window.localStorage.getItem("kandev.taskColors"))) + .toBeNull(); + + await expect( + sidebar.row(importedTask.task_id).getByTestId("task-item-color-marker"), + ).toHaveAttribute("data-color-token", "green"); + await expect( + sidebar.row(existingTask.task_id).getByTestId("task-item-color-marker"), + ).toHaveAttribute("data-color-token", "blue"); + }); + + test("saves a manual color and restores it after a reload", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Desktop manual color task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const navTask = await apiClient.seedTask(seedData.workspaceId, "Manual colors desktop nav", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${navTask.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const sidebar = new SidebarTasksPage(testPage); + const row = sidebar.row(task.task_id); + const marker = row.getByTestId("task-item-color-marker"); + await expect(row).toBeVisible(); + await expect(marker).toHaveCount(0); + + await sidebar.rightClick(task.task_id); + const colorMenu = testPage.getByRole("menuitem", { name: /color/i }); + await expect(colorMenu).toBeVisible(); + await colorMenu.hover(); + const blueMenuItem = testPage.getByRole("menuitem", { name: "Blue", exact: true }); + await expect(blueMenuItem).toBeVisible(); + await blueMenuItem.click(); + + await expect(marker).toHaveAttribute("data-color-token", "blue"); + await expect + .poll(async () => { + const { settings } = await apiClient.getUserSettings(); + const colors = settings.sidebar_task_colors as Record | undefined; + return colors?.[task.task_id]; + }) + .toBe("blue"); + + await testPage.reload(); + await session.waitForLoad(); + await expect(sidebar.row(task.task_id).getByTestId("task-item-color-marker")).toHaveAttribute( + "data-color-token", + "blue", + ); + await expect(testPage).toHaveURL(new RegExp(`/t/${navTask.task_id}$`)); + expect(await testPage.evaluate(() => localStorage.getItem("kandev.taskColors"))).toBeNull(); + }); +}); diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx index 2db818154b..2908bad319 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx @@ -212,6 +212,10 @@ describe("useRemoteRepositories provider results", () => { expect(result.current.repos[1].defaultBranch).toBe("trunk"); expect(result.current.repos[2].defaultBranch).toBe(""); expect(result.current.availableProviders).toEqual(["github", "azure_devops"]); + expect(result.current.error).toEqual(new Error("GitLab unavailable")); + expect(result.current.sourceErrors).toEqual([ + { provider: "gitlab", error: new Error("GitLab unavailable") }, + ]); expect(result.current.unavailable).toBe(false); }); diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.ts b/apps/web/hooks/domains/integrations/use-remote-repositories.ts index 35f236ea2d..363e82a8e1 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.ts +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.ts @@ -34,8 +34,10 @@ export type UseRemoteRepositoriesResult = { availableProviders: RemoteRepositoryProvider[]; loading: boolean; error: Error | null; + sourceErrors?: RemoteRepositorySourceError[]; unavailable: boolean; search: (query: string) => void; + refresh?: () => void; matchesURL?: (url: string) => boolean; }; @@ -64,6 +66,12 @@ async function loadAzureRepositories(workspaceId: string): Promise { + if (!workspaceId) return { repos: [], availableProviders: [], sourceErrors: [] }; const githubRequest = workspaceId ? fetchAccessibleRepos({ workspaceId, limit: 100 }) : Promise.reject(new Error("workspace is required for GitHub repositories")); @@ -123,7 +132,7 @@ async function loadPluginRepositories( query: string, signal: AbortSignal, ): Promise { - if (!workspaceId) return { repos: [], availableProviders: [] }; + if (!workspaceId) return { repos: [], availableProviders: [], sourceErrors: [] }; return settleRepositoryRequests( pluginProviders.map((provider) => ({ provider: provider.id, @@ -139,9 +148,15 @@ async function settleRepositoryRequests( const availableProviders = results.flatMap((result, index) => result.status === "fulfilled" ? [requests[index]!.provider] : [], ); + const sourceErrors = results.flatMap((result, index) => + result.status === "rejected" + ? [{ provider: requests[index]!.provider, error: toError(result.reason) }] + : [], + ); return { repos: results.flatMap((result) => (result.status === "fulfilled" ? result.value : [])), availableProviders, + sourceErrors, }; } @@ -204,84 +219,136 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value ? value : undefined; } +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +type RepositorySourceState = { + repos: RemoteRepository[]; + availableProviders: RemoteRepositoryProvider[]; + sourceErrors: RemoteRepositorySourceError[]; + loading: boolean; +}; + +function useBuiltInRepositorySource( + workspaceId: string, + refreshVersion: number, +): RepositorySourceState { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [sourceErrors, setSourceErrors] = useState([]); + const [availableProviders, setAvailableProviders] = useState([]); + + useEffect(() => { + let cancelled = false; + setRepos([]); + setAvailableProviders([]); + setSourceErrors([]); + setLoading(true); + loadBuiltInRepositories(workspaceId) + .then((result) => { + if (cancelled) return; + setRepos(result.repos); + setAvailableProviders(result.availableProviders); + setSourceErrors(result.sourceErrors); + }) + .catch((cause) => { + if (!cancelled) setSourceErrors([{ provider: "built-in", error: toError(cause) }]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refreshVersion, workspaceId]); + + return { repos, availableProviders, sourceErrors, loading }; +} + +function usePluginRepositorySource( + workspaceId: string, + pluginProviders: PluginRepositoryProviderRegistration[], + debouncedQuery: string, + refreshVersion: number, +): RepositorySourceState { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [sourceErrors, setSourceErrors] = useState([]); + const [availableProviders, setAvailableProviders] = useState([]); + const generationRef = useRef(0); + + useEffect(() => { + setRepos([]); + setAvailableProviders([]); + setSourceErrors([]); + }, [workspaceId, pluginProviders]); + + useEffect(() => { + const generation = ++generationRef.current; + const controller = new AbortController(); + setSourceErrors([]); + setLoading(true); + loadPluginRepositories(workspaceId, pluginProviders, debouncedQuery, controller.signal) + .then((result) => { + if (generation !== generationRef.current) return; + setRepos(result.repos); + setAvailableProviders(result.availableProviders); + setSourceErrors(result.sourceErrors); + }) + .catch((cause) => { + if (generation !== generationRef.current || controller.signal.aborted) return; + setSourceErrors([{ provider: "plugin", error: toError(cause) }]); + }) + .finally(() => { + if (generation === generationRef.current) setLoading(false); + }); + return () => controller.abort(); + }, [debouncedQuery, pluginProviders, refreshVersion, workspaceId]); + + return { repos, availableProviders, sourceErrors, loading }; +} + export function useRemoteRepositories(workspaceId: string): UseRemoteRepositoriesResult { - const [builtInRepos, setBuiltInRepos] = useState([]); - const [pluginRepos, setPluginRepos] = useState([]); const [query, setQuery] = useState(""); const debouncedQuery = useDebouncedValue(query, 250); - const [builtInsLoading, setBuiltInsLoading] = useState(true); - const [pluginsLoading, setPluginsLoading] = useState(true); - const [error, setError] = useState(null); - const [builtInProviders, setBuiltInProviders] = useState([]); - const [pluginProviderIds, setPluginProviderIds] = useState([]); - const pluginGeneration = useRef(0); + const [refreshVersion, setRefreshVersion] = useState(0); const registry = usePluginRegistry(); const registryVersion = registry.getVersion(); const pluginProviders = useMemo( () => registry.getRepositoryProviders(), [registry, registryVersion], ); - - useEffect(() => { - let cancelled = false; - setBuiltInRepos([]); - setBuiltInProviders([]); - setError(null); - setBuiltInsLoading(true); - loadBuiltInRepositories(workspaceId) - .then((result) => { - if (cancelled) return; - setBuiltInRepos(result.repos); - setBuiltInProviders(result.availableProviders); - }) - .catch((cause) => { - if (!cancelled) setError(cause instanceof Error ? cause : new Error(String(cause))); - }) - .finally(() => { - if (!cancelled) setBuiltInsLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceId]); - - useEffect(() => { - setPluginRepos([]); - setPluginProviderIds([]); - }, [workspaceId, pluginProviders]); - - useEffect(() => { - const generation = ++pluginGeneration.current; - const controller = new AbortController(); - setPluginsLoading(true); - loadPluginRepositories(workspaceId, pluginProviders, debouncedQuery, controller.signal) - .then((result) => { - if (generation !== pluginGeneration.current) return; - setPluginRepos(result.repos); - setPluginProviderIds(result.availableProviders); - }) - .catch((cause) => { - if (generation !== pluginGeneration.current || controller.signal.aborted) return; - setError(cause instanceof Error ? cause : new Error(String(cause))); - }) - .finally(() => { - if (generation === pluginGeneration.current) setPluginsLoading(false); - }); - return () => controller.abort(); - }, [workspaceId, pluginProviders, debouncedQuery]); + const builtInSource = useBuiltInRepositorySource(workspaceId, refreshVersion); + const pluginSource = usePluginRepositorySource( + workspaceId, + pluginProviders, + debouncedQuery, + refreshVersion, + ); const repos = useMemo(() => { const needle = query.trim().toLowerCase(); - const allRepos = [...builtInRepos, ...pluginRepos]; + const allRepos = [...builtInSource.repos, ...pluginSource.repos]; if (!needle) return allRepos; - return allRepos.filter((repo) => repo.fullName.toLowerCase().includes(needle)); - }, [builtInRepos, pluginRepos, query]); + return allRepos.filter((repo) => + [repo.fullName, repo.providerHost, repo.url].some((value) => + value?.toLowerCase().includes(needle), + ), + ); + }, [builtInSource.repos, pluginSource.repos, query]); const availableProviders = useMemo( - () => [...builtInProviders, ...pluginProviderIds], - [builtInProviders, pluginProviderIds], + () => [...builtInSource.availableProviders, ...pluginSource.availableProviders], + [builtInSource.availableProviders, pluginSource.availableProviders], ); - const loading = builtInsLoading || pluginsLoading; + const loading = builtInSource.loading || pluginSource.loading; + const sourceErrors = useMemo( + () => [...builtInSource.sourceErrors, ...pluginSource.sourceErrors], + [builtInSource.sourceErrors, pluginSource.sourceErrors], + ); + const error = sourceErrors[0]?.error ?? null; const search = useCallback((value: string) => setQuery(value), []); + const refresh = useCallback(() => setRefreshVersion((version) => version + 1), []); const matchesURL = useCallback( (url: string) => looksLikeSupportedRemoteURL(url) || @@ -293,8 +360,10 @@ export function useRemoteRepositories(workspaceId: string): UseRemoteRepositorie availableProviders, loading, error, + sourceErrors, unavailable: !loading && availableProviders.length === 0, search, + refresh, matchesURL, }; } diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index 08f4a1937b..5de3b087b7 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -44,7 +44,7 @@ const mockState = { setActiveTurn: vi.fn(), reconcileActiveTurnAfterHydration: vi.fn(), }; -const mockStoreApi = { getState: () => mockState }; +const mockStore = { getState: () => mockState }; vi.mock("@/lib/api/domains/session-api", () => ({ listSessionTurns: (...args: unknown[]) => mockListSessionTurns(...args), @@ -56,7 +56,7 @@ vi.mock("@/lib/ws/connection", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), - useAppStoreApi: () => mockStoreApi, + useAppStoreApi: () => mockStore, })); import { taskId, sessionId } from "@/lib/types/ids"; diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 68e6b071b8..bf9b8172bc 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -29,6 +29,10 @@ let mockState: MockState; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), + useAppStoreApi: () => ({ + getState: () => mockState, + setState: vi.fn(), + }), })); vi.mock("@/lib/api/domains/settings-api", () => ({ @@ -83,6 +87,8 @@ function makeUnloadedSettings(): UserSettingsState { threadActiveViewId: null, threadViewDraft: null, sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + sidebarTaskColorAutomation: { enabled: false, rules: [] }, + sidebarTaskColors: {}, taskCreateLastUsed: { repositoryId: null, branch: null, diff --git a/apps/web/hooks/use-task-color-migration.test.tsx b/apps/web/hooks/use-task-color-migration.test.tsx new file mode 100644 index 0000000000..efed34e084 --- /dev/null +++ b/apps/web/hooks/use-task-color-migration.test.tsx @@ -0,0 +1,186 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { defaultState } from "@/lib/state/default-state"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; +import { TASK_COLORS_STORAGE_KEY } from "@/lib/task-colors"; +import type { UserSettingsResponse } from "@/lib/types/http"; +import { useTaskColorMigration } from "./use-task-color-migration"; + +const mockUpdateUserSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: mockUpdateUserSettings, +})); + +function response( + colors: Record, + revision: number, +): UserSettingsResponse { + return { + settings: { + user_id: "default-user", + workspace_id: "" as UserSettingsResponse["settings"]["workspace_id"], + repository_ids: [], + sidebar_task_colors: colors, + revision, + updated_at: "2026-09-03T00:00:00Z", + }, + shell_options: [], + }; +} + +let capturedStore: ReturnType | null = null; + +function CaptureStore() { + capturedStore = useAppStoreApi(); + return null; +} + +function installUserSettingsSpy() { + const currentStore = capturedStore; + if (!currentStore) throw new Error("test store was not captured"); + const setUserSettings = vi.fn((next: UserSettingsState) => { + currentStore.setState((state) => ({ ...state, userSettings: next })); + }); + currentStore.setState({ setUserSettings }); + return setUserSettings; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +beforeEach(() => { + mockUpdateUserSettings.mockReset(); + capturedStore = null; + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("useTaskColorMigration", () => { + it("imports valid legacy values only after settings are loaded and removes the key after success", async () => { + window.localStorage.setItem( + TASK_COLORS_STORAGE_KEY, + JSON.stringify({ + "task-existing": "blue", + "task-cleared": "pink", + "task-new": "green", + "task-invalid": "gray", + }), + ); + mockUpdateUserSettings.mockResolvedValue( + response({ "task-existing": "red", "task-cleared": null, "task-new": "green" }, 2), + ); + + renderHook(() => useTaskColorMigration(true), { wrapper }); + + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + sidebar_task_color_patch: { + colors: { + "task-existing": "blue", + "task-cleared": "pink", + "task-new": "green", + }, + if_missing: true, + }, + }); + await waitFor(() => expect(window.localStorage.getItem(TASK_COLORS_STORAGE_KEY)).toBeNull()); + expect(capturedStore?.getState().userSettings.sidebarTaskColors).toEqual({ + "task-existing": "red", + "task-cleared": null, + "task-new": "green", + }); + }); + + it("keeps the legacy key and does not use it for display when a batch fails", async () => { + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, JSON.stringify({ "task-new": "green" })); + mockUpdateUserSettings.mockRejectedValue(new Error("offline")); + + renderHook(() => useTaskColorMigration(true), { wrapper }); + + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + expect(window.localStorage.getItem(TASK_COLORS_STORAGE_KEY)).not.toBeNull(); + expect(capturedStore?.getState().userSettings.sidebarTaskColors).toEqual({ + "task-existing": "red", + "task-cleared": null, + }); + }); + + it("does not overwrite a newer settings revision when a migration response is delayed", async () => { + let resolveUpdate: ((value: UserSettingsResponse) => void) | undefined; + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, JSON.stringify({ "task-new": "green" })); + mockUpdateUserSettings.mockReturnValue( + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + + renderHook(() => useTaskColorMigration(true), { wrapper }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + + act(() => { + capturedStore?.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + revision: 3, + sidebarTaskColors: { "task-existing": "purple", "task-new": "pink" }, + }, + })); + }); + + const setUserSettings = installUserSettingsSpy(); + + act(() => resolveUpdate?.(response({ "task-new": "green" }, 2))); + await waitFor(() => expect(window.localStorage.getItem(TASK_COLORS_STORAGE_KEY)).toBeNull()); + expect(capturedStore?.getState().userSettings.revision).toBe(3); + expect(setUserSettings).not.toHaveBeenCalled(); + expect(capturedStore?.getState().userSettings.sidebarTaskColors).toEqual({ + "task-existing": "purple", + "task-new": "pink", + }); + }); + + it("does not start until the server settings state is loaded", async () => { + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, JSON.stringify({ "task-new": "green" })); + mockUpdateUserSettings.mockResolvedValue(response({ "task-new": "green" }, 2)); + + const { rerender } = renderHook(() => useTaskColorMigration(true), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockUpdateUserSettings).not.toHaveBeenCalled(); + + act(() => { + rerender(); + }); + expect(mockUpdateUserSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/use-task-color-migration.ts b/apps/web/hooks/use-task-color-migration.ts new file mode 100644 index 0000000000..9f13a3d993 --- /dev/null +++ b/apps/web/hooks/use-task-color-migration.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect } from "react"; +import type { StoreApi } from "zustand"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { clearLegacyTaskColors, readLegacyTaskColors, type TaskColor } from "@/lib/task-colors"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { AppState } from "@/lib/state/store"; + +export const TASK_COLOR_MIGRATION_BATCH_SIZE = 500; + +let migrationPromise: Promise | null = null; + +export function chunkTaskColors( + colors: Record, + batchSize = TASK_COLOR_MIGRATION_BATCH_SIZE, +): Array> { + const entries = Object.entries(colors); + const batches: Array> = []; + for (let start = 0; start < entries.length; start += batchSize) { + batches.push(Object.fromEntries(entries.slice(start, start + batchSize))); + } + return batches; +} + +export async function migrateLegacyTaskColors(store: StoreApi): Promise { + const legacy = readLegacyTaskColors(); + const batches = chunkTaskColors(legacy); + if (batches.length === 0) { + clearLegacyTaskColors(); + return; + } + + for (const colors of batches) { + const response = await updateUserSettings({ + sidebar_task_color_patch: { + colors, + if_missing: true, + }, + }); + const current = store.getState().userSettings; + const order = compareUserSettingsRevisions(response.settings.revision, current.revision); + if (order !== 1) continue; + store.getState().setUserSettings(mapUserSettingsResponse(response, current)); + } + clearLegacyTaskColors(); +} + +export function __resetTaskColorMigrationForTests() { + migrationPromise = null; +} + +export function useTaskColorMigration(enabled = true) { + const store = useAppStoreApi(); + const loaded = useAppStore((state) => state.userSettings.loaded); + + useEffect(() => { + if (!enabled || !loaded || migrationPromise) return; + migrationPromise = migrateLegacyTaskColors(store) + .catch(() => undefined) + .finally(() => { + migrationPromise = null; + }); + }, [enabled, loaded, store]); +} diff --git a/apps/web/hooks/use-task-color.test.tsx b/apps/web/hooks/use-task-color.test.tsx new file mode 100644 index 0000000000..4551090cf0 --- /dev/null +++ b/apps/web/hooks/use-task-color.test.tsx @@ -0,0 +1,164 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { defaultState } from "@/lib/state/default-state"; +import { TASK_COLORS_STORAGE_KEY } from "@/lib/task-colors"; +import type { UserSettingsResponse } from "@/lib/types/http"; +import { useSetTaskColor, useTaskColor } from "./use-task-color"; + +const mockUpdateUserSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: mockUpdateUserSettings, +})); + +function response( + colors: Record, + revision: number, +): UserSettingsResponse { + return { + settings: { + user_id: "default-user", + workspace_id: "" as UserSettingsResponse["settings"]["workspace_id"], + repository_ids: [], + sidebar_task_colors: colors, + revision, + updated_at: "2026-09-03T00:00:00Z", + }, + shell_options: [], + }; +} + +let capturedStore: ReturnType | null = null; + +function CaptureStore() { + capturedStore = useAppStoreApi(); + return null; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); +} + +beforeEach(() => { + mockUpdateUserSettings.mockReset(); + capturedStore = null; + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("useTaskColor", () => { + it("reads the confirmed server-backed color instead of legacy browser storage", () => { + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, JSON.stringify({ "task-1": "pink" })); + const { result } = renderHook(() => useTaskColor("task-1"), { wrapper }); + + expect(result.current).toBe("red"); + }); + + it("optimistically sends one narrow normal patch and adopts its response", async () => { + mockUpdateUserSettings.mockResolvedValue(response({ "task-1": "blue" }, 2)); + const { result } = renderHook( + () => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }), + { wrapper }, + ); + + act(() => result.current.setColor("task-1", "blue")); + expect(result.current.color).toBe("blue"); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + expect(mockUpdateUserSettings).toHaveBeenCalledWith({ + sidebar_task_color_patch: { + colors: { "task-1": "blue" }, + if_missing: false, + }, + }); + await waitFor(() => expect(result.current.color).toBe("blue")); + }); + + it("rolls back a failed write to the confirmed color and reports a localized error", async () => { + mockUpdateUserSettings.mockRejectedValue(new Error("offline")); + const { result } = renderHook( + () => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }), + { wrapper }, + ); + + act(() => result.current.setColor("task-1", "blue")); + expect(result.current.color).toBe("blue"); + await waitFor(() => expect(result.current.color).toBe("red")); + }); + + it("does not let a delayed stale response replace a newer settings event", async () => { + let resolveUpdate: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings.mockReturnValue( + new Promise((resolve) => { + resolveUpdate = resolve; + }), + ); + const { result } = renderHook( + () => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }), + { wrapper }, + ); + + act(() => result.current.setColor("task-1", "blue")); + act(() => { + capturedStore?.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + revision: 3, + sidebarTaskColors: { "task-1": "green" }, + }, + })); + }); + expect(result.current.color).toBe("green"); + + act(() => resolveUpdate?.(response({ "task-1": "blue" }, 2))); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + expect(result.current.color).toBe("green"); + }); + + it("keeps the first persisted color when a later queued edit fails without a WS echo", async () => { + let resolveFirst: ((value: UserSettingsResponse) => void) | undefined; + mockUpdateUserSettings.mockImplementation(() => { + if (mockUpdateUserSettings.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.reject(new Error("offline")); + }); + const { result } = renderHook( + () => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }), + { wrapper }, + ); + + act(() => { + result.current.setColor("task-1", "blue"); + result.current.setColor("task-1", "green"); + }); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1)); + + act(() => resolveFirst?.(response({ "task-1": "blue" }, 2))); + await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.color).toBe("blue")); + }); +}); diff --git a/apps/web/hooks/use-task-color.ts b/apps/web/hooks/use-task-color.ts index af9dc4639a..4520409e80 100644 --- a/apps/web/hooks/use-task-color.ts +++ b/apps/web/hooks/use-task-color.ts @@ -1,34 +1,148 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; -import { - getTaskColor, - setTaskColor, - TASK_COLORS_CHANGED_EVENT, - TASK_COLORS_STORAGE_KEY, - type TaskColor, -} from "@/lib/task-colors"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { useTranslation } from "react-i18next"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; +import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync"; +import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings"; +import { type TaskColor } from "@/lib/task-colors"; -function subscribe(cb: () => void): () => void { - if (typeof window === "undefined") return () => {}; - const onStorage = (e: StorageEvent) => { - if (e.key === null || e.key === TASK_COLORS_STORAGE_KEY) cb(); - }; - window.addEventListener(TASK_COLORS_CHANGED_EVENT, cb); - window.addEventListener("storage", onStorage); - return () => { - window.removeEventListener(TASK_COLORS_CHANGED_EVENT, cb); - window.removeEventListener("storage", onStorage); +type TaskColorMap = Record; + +function cloneTaskColorMap(colors: TaskColorMap): TaskColorMap { + return { ...colors }; +} + +function optimisticTaskColorSettings( + settings: ReturnType["getState"]>["userSettings"], + taskId: string, + color: TaskColor | null, +) { + return { + ...settings, + sidebarTaskColors: { + ...settings.sidebarTaskColors, + [taskId]: color, + }, + loaded: true, }; } -const getServerSnapshot = (): TaskColor | null => null; +function responseIsCurrent( + responseRevision: number | undefined, + currentRevision: number | null, + settingsAtSubmit: object, + currentSettings: object, +) { + const order = compareUserSettingsRevisions(responseRevision, currentRevision); + return order === null ? currentSettings === settingsAtSubmit : order >= 0; +} export function useTaskColor(taskId: string | undefined): TaskColor | null { - const getSnapshot = useCallback(() => (taskId ? getTaskColor(taskId) : null), [taskId]); - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return useAppStore((state) => { + if (!taskId) return null; + return state.userSettings.sidebarTaskColors[taskId] ?? null; + }); } export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void { - return useCallback((taskId, color) => setTaskColor(taskId, color), []); + const store = useAppStoreApi(); + const settings = useAppStore((state) => state.userSettings); + const { toast } = useToast(); + const { t } = useTranslation(); + const confirmedColorsRef = useRef(cloneTaskColorMap(settings.sidebarTaskColors)); + const confirmedRevisionRef = useRef(settings.revision); + const operationRef = useRef(0); + const pendingRef = useRef(0); + const sync = useMemo( + () => + createQueuedUserSettingsSyncWithResponse<{ + taskId: string; + color: TaskColor | null; + }>( + ({ taskId, color }): UserSettingsUpdatePayload => ({ + sidebar_task_color_patch: { + colors: { [taskId]: color }, + if_missing: false, + }, + }), + ), + [], + ); + + useEffect(() => { + const current = store.getState().userSettings; + const order = compareUserSettingsRevisions(current.revision, confirmedRevisionRef.current); + if (pendingRef.current === 0) { + if (order === -1) return; + confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors); + confirmedRevisionRef.current = current.revision; + return; + } + if (order === 1) { + confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors); + confirmedRevisionRef.current = current.revision; + } + }, [settings, store]); + + return useCallback( + (taskId: string, color: TaskColor | null) => { + if (!taskId) return; + const operation = ++operationRef.current; + pendingRef.current += 1; + const settingsAtSubmit = store.getState().userSettings; + store + .getState() + .setUserSettings(optimisticTaskColorSettings(settingsAtSubmit, taskId, color)); + + void sync({ taskId, color }) + .then((response) => { + const latest = store.getState().userSettings; + const mapped = mapUserSettingsResponse(response, latest); + const responseOrder = compareUserSettingsRevisions( + mapped.revision, + confirmedRevisionRef.current, + ); + if (responseOrder !== 1) return; + + confirmedColorsRef.current = cloneTaskColorMap(mapped.sidebarTaskColors); + confirmedRevisionRef.current = mapped.revision; + + if ( + operation !== operationRef.current || + !responseIsCurrent( + response.settings.revision, + latest.revision, + settingsAtSubmit, + latest, + ) + ) { + return; + } + store.getState().setUserSettings(mapped); + }) + .catch(() => { + if (operation !== operationRef.current) return; + const latest = store.getState().userSettings; + const order = compareUserSettingsRevisions(latest.revision, confirmedRevisionRef.current); + if (order === 1) { + confirmedColorsRef.current = cloneTaskColorMap(latest.sidebarTaskColors); + confirmedRevisionRef.current = latest.revision; + } + store.getState().setUserSettings({ + ...latest, + sidebarTaskColors: cloneTaskColorMap(confirmedColorsRef.current), + loaded: true, + }); + toast({ variant: "error", description: t("task:manualColorSaveError") }); + }) + .finally(() => { + pendingRef.current -= 1; + }); + }, + [store, sync, t, toast], + ); } diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index c2d9dd1208..26f86ddca9 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -7,6 +7,7 @@ import type { KanbanState, TaskDependencyRef } from "@/lib/state/slices/kanban/t import type { ForegroundActivity, TaskPendingAction, + TaskOrigin, TaskPriority, TaskState, TaskSessionState, @@ -36,6 +37,7 @@ export type TaskLike = { position?: number; state?: TaskState; priority?: TaskPriority; + origin?: TaskOrigin | string | null; repositories?: Array<{ id?: string; repository_id: string; @@ -69,12 +71,12 @@ export type TaskLike = { session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; primary_executor_id?: string | null; + primary_executor_profile_id?: string | null; primary_executor_type?: string | null; primary_executor_name?: string | null; primary_agent_name?: string | null; primary_agent_profile_id?: string | null; labels?: string | string[] | null; - origin?: string | null; is_remote_executor?: boolean; parent_id?: string | null; assignee_user_id?: string; @@ -166,6 +168,16 @@ function pickWorkspaceFolders(source: TaskLike): KanbanTask["workspaceFolders"] return source.workspace_folders?.map((folder) => ({ ...folder })); } +function primaryExecutorProjection(source: TaskLike) { + return { + primaryExecutorId: source.primary_executor_id ?? undefined, + primaryExecutorProfileId: source.primary_executor_profile_id ?? undefined, + primaryExecutorType: source.primary_executor_type ?? undefined, + primaryExecutorName: source.primary_executor_name ?? undefined, + isRemoteExecutor: source.is_remote_executor ?? false, + }; +} + /** * Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket * payload. Both paths share this helper so a single publisher change can never @@ -218,6 +230,7 @@ export function preserveOmittedExecutorFields(merged: KanbanTask, existing: Kanb export function copyPrimaryExecutorFields(merged: KanbanTask, existing: KanbanTask): void { merged.primaryExecutorId = existing.primaryExecutorId; + merged.primaryExecutorProfileId = existing.primaryExecutorProfileId; merged.primaryExecutorType = existing.primaryExecutorType; merged.primaryExecutorName = existing.primaryExecutorName; merged.isRemoteExecutor = existing.isRemoteExecutor; @@ -245,6 +258,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { description: source.description ?? undefined, autopilot: source.autopilot, priority: source.priority, + origin: source.origin, position: source.position ?? 0, state: source.state, repositoryId: pickRepositoryId(source), @@ -260,14 +274,10 @@ export function toKanbanTask(source: TaskLike): KanbanTask { activeSubagentCount: source.active_subagent_count ?? undefined, sessionCount: source.session_count ?? undefined, reviewStatus: source.review_status ?? undefined, - primaryExecutorId: source.primary_executor_id ?? undefined, - primaryExecutorType: source.primary_executor_type ?? undefined, - primaryExecutorName: source.primary_executor_name ?? undefined, + ...primaryExecutorProjection(source), primaryAgentProfileId: source.primary_agent_profile_id ?? undefined, primaryAgentName: source.primary_agent_name ?? undefined, labels: pickLabels(source), - origin: source.origin ?? undefined, - isRemoteExecutor: source.is_remote_executor ?? false, assigneeUserId: pickAssignee(source.assignee_user_id), parentTaskId: source.parent_id ?? undefined, workspaceMode: workspaceModeFromMetadata(source.metadata), diff --git a/apps/web/lib/sidebar/repository-rule-catalog.test.ts b/apps/web/lib/sidebar/repository-rule-catalog.test.ts new file mode 100644 index 0000000000..8a1f023d11 --- /dev/null +++ b/apps/web/lib/sidebar/repository-rule-catalog.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { RemoteRepository } from "@/hooks/domains/integrations/use-remote-repositories"; +import type { Repository } from "@/lib/types/http"; +import { buildRepositoryRuleCatalog, repositoryRuleTargetKey } from "./repository-rule-catalog"; + +const savedRepository = (overrides: Partial): Repository => + ({ + id: "repo-1", + workspace_id: "workspace-1", + name: "web", + source_type: "local", + local_path: "/work/web", + provider: "", + provider_repo_id: "", + provider_owner: "", + provider_name: "", + default_branch: "main", + worktree_branch_prefix: "task/", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: "", + updated_at: "", + ...overrides, + }) as Repository; + +const remoteRepository = (overrides: Partial): RemoteRepository => ({ + provider: "github", + id: "acme/web", + owner: "acme", + name: "web", + fullName: "acme/web", + url: "https://github.com/acme/web", + defaultBranch: "main", + private: false, + ...overrides, +}); + +describe("buildRepositoryRuleCatalog", () => { + it("deduplicates saved and remote identities while retaining source ordering", () => { + const options = buildRepositoryRuleCatalog({ + workspaceRepositories: [ + savedRepository({ + provider: "github", + provider_repo_id: "acme/web", + provider_owner: "acme", + provider_name: "web", + local_path: "/work/web", + }), + ], + localRepositories: [], + remoteRepositories: [remoteRepository({})], + }); + + expect(options).toHaveLength(1); + expect(options[0]).toMatchObject({ label: "acme/web", group: "workspace", available: true }); + }); + + it("includes plugin repositories and filters by label or identity details", () => { + const options = buildRepositoryRuleCatalog({ + workspaceRepositories: [], + localRepositories: [], + remoteRepositories: [ + remoteRepository({ + provider: "bitbucket", + id: "42", + fullName: "platform/api", + providerHost: "bitbucket.org", + }), + ], + query: "bitbucket", + }); + expect(options).toHaveLength(1); + expect(options[0]).toMatchObject({ group: "plugin", label: "platform/api" }); + }); + + it("keeps a missing persisted target available for replacement", () => { + const target = { kind: "local" as const, path: "/old/repository" }; + const options = buildRepositoryRuleCatalog({ + workspaceRepositories: [], + localRepositories: [], + remoteRepositories: [], + unavailableTargets: [{ target, label: "Old repository" }], + }); + expect(options).toEqual([ + { + key: repositoryRuleTargetKey(target), + label: "Old repository", + group: "unavailable", + target, + available: false, + }, + ]); + }); +}); diff --git a/apps/web/lib/sidebar/repository-rule-catalog.ts b/apps/web/lib/sidebar/repository-rule-catalog.ts new file mode 100644 index 0000000000..2ccb11ca2e --- /dev/null +++ b/apps/web/lib/sidebar/repository-rule-catalog.ts @@ -0,0 +1,119 @@ +import type { RemoteRepository } from "@/hooks/domains/integrations/use-remote-repositories"; +import { REMOTE_REPOSITORY_PROVIDERS } from "@/hooks/domains/integrations/use-remote-repositories"; +import type { LocalRepository, Repository } from "@/lib/types/http"; +import { repositorySlug } from "@/lib/repository-slug"; +import { + repositoryIdentityForLocalRepository, + repositoryIdentityForRemoteRepository, + repositoryIdentityForSavedRepository, + type RepositoryRuleTarget, +} from "./repository-rule-identity"; + +export type RepositoryRuleCatalogGroup = + | "workspace" + | "local" + | "remote" + | "plugin" + | "unavailable"; + +export type RepositoryRuleCatalogOption = { + key: string; + label: string; + secondaryLabel?: string; + group: RepositoryRuleCatalogGroup; + target: RepositoryRuleTarget; + available: boolean; +}; + +export type RepositoryRuleCatalogInput = { + workspaceRepositories: readonly Repository[]; + localRepositories: readonly LocalRepository[]; + remoteRepositories: readonly RemoteRepository[]; + unavailableTargets?: readonly { target: RepositoryRuleTarget; label?: string }[]; + query?: string; +}; + +/** + * Builds one repository picker from every source already available to the + * workspace-source UI. Stable repository identities deduplicate saved and + * discovered rows, while unavailable persisted targets remain selectable so a + * user can replace or remove an old rule. + */ +export function buildRepositoryRuleCatalog( + input: RepositoryRuleCatalogInput, +): RepositoryRuleCatalogOption[] { + const options: RepositoryRuleCatalogOption[] = []; + const seen = new Set(); + + const add = (option: Omit) => { + const key = repositoryRuleTargetKey(option.target); + if (seen.has(key)) return; + seen.add(key); + options.push({ ...option, key, available: true }); + }; + + for (const repository of input.workspaceRepositories) { + add({ + label: repositorySlug(repository), + secondaryLabel: repository.local_path || undefined, + group: "workspace", + target: repositoryIdentityForSavedRepository(repository), + }); + } + for (const repository of input.localRepositories) { + add({ + label: repository.name || repository.path, + secondaryLabel: repository.path, + group: "local", + target: repositoryIdentityForLocalRepository(repository), + }); + } + for (const repository of input.remoteRepositories) { + add({ + label: repository.fullName || `${repository.owner}/${repository.name}`, + secondaryLabel: repository.providerHost || repository.url, + group: isBuiltInProvider(repository.provider) ? "remote" : "plugin", + target: repositoryIdentityForRemoteRepository(repository), + }); + } + + for (const unavailable of input.unavailableTargets ?? []) { + const key = repositoryRuleTargetKey(unavailable.target); + if (seen.has(key)) continue; + seen.add(key); + options.push({ + key, + label: unavailable.label || key, + group: "unavailable", + target: unavailable.target, + available: false, + }); + } + + const needle = input.query?.trim().toLowerCase(); + if (!needle) return options; + return options.filter((option) => + [option.label, option.secondaryLabel].some((value) => value?.toLowerCase().includes(needle)), + ); +} + +export function repositoryRuleTargetKey(target: RepositoryRuleTarget): string { + switch (target.kind) { + case "workspace": + return JSON.stringify([target.kind, target.workspace_id, target.repository_id]); + case "provider": + return JSON.stringify([ + target.kind, + target.provider_id, + target.host, + target.scope, + target.provider_repository_id, + ]); + case "local": + return JSON.stringify([target.kind, target.path]); + } +} + +function isBuiltInProvider(provider: string): boolean { + return (REMOTE_REPOSITORY_PROVIDERS as readonly string[]).includes(provider); +} diff --git a/apps/web/lib/sidebar/repository-rule-identity.test.ts b/apps/web/lib/sidebar/repository-rule-identity.test.ts new file mode 100644 index 0000000000..d2228b845d --- /dev/null +++ b/apps/web/lib/sidebar/repository-rule-identity.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + repositoryIdentityForSavedRepository, + repositoryIdentityForTaskRepository, + repositoryRuleTargetMatches, + type RepositoryRuleTarget, +} from "./repository-rule-identity"; + +const WORKSPACE_A = "workspace-a"; +const WORKSPACE_B = "workspace-b"; +const GITHUB = "github"; +const GITHUB_HOST = "github.com"; +const ACME = "acme"; +const ACME_WIDGET = "acme/widget"; + +describe("repository rule identity", () => { + it("prefers provider identity over workspace identity", () => { + expect( + repositoryIdentityForSavedRepository({ + id: "workspace-repo", + workspace_id: WORKSPACE_A, + provider: GITHUB, + provider_repo_id: ACME_WIDGET, + provider_host: GITHUB_HOST, + provider_scope: ACME, + local_path: "/tmp/widget", + }), + ).toEqual({ + kind: "provider", + provider_id: GITHUB, + host: GITHUB_HOST, + scope: ACME, + provider_repository_id: ACME_WIDGET, + }); + }); + + it("matches any attached repository and respects workspace scoping", () => { + const target: RepositoryRuleTarget = { + kind: "provider", + provider_id: "github", + host: "github.com", + scope: "acme", + provider_repository_id: "acme/widget", + }; + expect( + repositoryRuleTargetMatches(target, "workspace-b", [ + repositoryIdentityForTaskRepository({ + repository_id: "other", + workspace_id: WORKSPACE_B, + provider: GITHUB, + provider_repo_id: ACME_WIDGET, + provider_host: GITHUB_HOST, + provider_scope: ACME, + local_path: "/tmp/other", + }), + ]), + ).toBe(true); + + expect( + repositoryRuleTargetMatches( + { kind: "workspace", workspace_id: "workspace-a", repository_id: "repo-1" }, + WORKSPACE_B, + [{ kind: "workspace", workspace_id: WORKSPACE_B, repository_id: "repo-1" }], + ), + ).toBe(false); + }); + + it("normalizes local paths before comparing them", () => { + const target: RepositoryRuleTarget = { kind: "local", path: "/work/project" }; + expect( + repositoryRuleTargetMatches(target, "workspace-a", [ + { kind: "local", path: "/work/project/" }, + ]), + ).toBe(true); + }); + + it("removes repeated trailing separators while preserving the root", () => { + expect( + repositoryIdentityForTaskRepository({ + repository_id: "repo-1", + local_path: "/work/project///", + }), + ).toEqual({ kind: "local", path: "/work/project" }); + expect( + repositoryIdentityForTaskRepository({ + repository_id: "repo-1", + local_path: "\\work\\project\\\\", + }), + ).toEqual({ kind: "local", path: "/work/project" }); + expect( + repositoryIdentityForTaskRepository({ + repository_id: "repo-1", + local_path: "////", + }), + ).toEqual({ kind: "local", path: "/" }); + }); +}); diff --git a/apps/web/lib/sidebar/repository-rule-identity.ts b/apps/web/lib/sidebar/repository-rule-identity.ts new file mode 100644 index 0000000000..cd0e8e44ce --- /dev/null +++ b/apps/web/lib/sidebar/repository-rule-identity.ts @@ -0,0 +1,203 @@ +import type { RemoteRepository } from "@/hooks/domains/integrations/use-remote-repositories"; +import type { LocalRepository } from "@/lib/types/http"; + +export type RepositoryRuleTarget = + | { kind: "workspace"; workspace_id: string; repository_id: string } + | { + kind: "provider"; + provider_id: string; + host: string; + scope: string; + provider_repository_id: string; + } + | { kind: "local"; path: string }; + +export type TaskRepositoryRuleIdentity = RepositoryRuleTarget; + +type RepositoryIdentitySource = { + id: string; + workspace_id: string; + provider: string; + provider_repo_id: string; + provider_host?: string; + provider_scope?: string; + provider_owner?: string; + remote_url?: string; + local_path: string; +}; + +export function repositoryIdentityForSavedRepository( + repository: RepositoryIdentitySource, +): RepositoryRuleTarget { + if (repository.provider && repository.provider_repo_id) { + return providerTarget( + repository.provider, + repository.provider_repo_id, + repository.provider_host, + repository.provider_scope || repository.provider_owner, + repository.remote_url, + ); + } + if (repository.local_path) + return { kind: "local", path: normalizeLocalRepositoryPath(repository.local_path) }; + return { + kind: "workspace", + workspace_id: repository.workspace_id, + repository_id: repository.id, + }; +} + +export function repositoryIdentityForTaskRepository(repository: { + repository_id: string; + workspace_id?: string; + provider?: string; + provider_repo_id?: string; + provider_host?: string; + provider_scope?: string; + local_path?: string; +}): RepositoryRuleTarget { + if (repository.provider && repository.provider_repo_id) { + return providerTarget( + repository.provider, + repository.provider_repo_id, + repository.provider_host, + repository.provider_scope, + undefined, + ); + } + if (repository.local_path) + return { kind: "local", path: normalizeLocalRepositoryPath(repository.local_path) }; + return { + kind: "workspace", + workspace_id: repository.workspace_id ?? "", + repository_id: repository.repository_id, + }; +} + +export function repositoryIdentityForRemoteRepository( + repository: RemoteRepository, +): RepositoryRuleTarget { + return providerTarget( + repository.provider, + repository.id, + repository.providerHost, + repository.providerScope || repository.owner, + repository.url, + ); +} + +export function repositoryIdentityForLocalRepository( + repository: LocalRepository, +): RepositoryRuleTarget { + return { kind: "local", path: normalizeLocalRepositoryPath(repository.path) }; +} + +export function repositoryRuleTargetMatches( + target: RepositoryRuleTarget, + taskWorkspaceId: string | undefined, + identities: readonly TaskRepositoryRuleIdentity[], +): boolean { + return identities.some((identity) => { + if (target.kind !== identity.kind) return false; + if (target.kind === "workspace" && identity.kind === "workspace") { + return ( + target.workspace_id === taskWorkspaceId && + identity.workspace_id === taskWorkspaceId && + target.repository_id === identity.repository_id + ); + } + if (target.kind === "provider" && identity.kind === "provider") { + return ( + target.provider_id === identity.provider_id && + target.host === identity.host && + target.scope === identity.scope && + target.provider_repository_id === identity.provider_repository_id + ); + } + return ( + target.kind === "local" && + identity.kind === "local" && + normalizeLocalRepositoryPath(target.path) === normalizeLocalRepositoryPath(identity.path) + ); + }); +} + +export function isRepositoryRuleTarget(value: unknown): value is RepositoryRuleTarget { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + if (candidate.kind === "workspace") { + return ( + typeof candidate.workspace_id === "string" && typeof candidate.repository_id === "string" + ); + } + if (candidate.kind === "provider") { + return ( + typeof candidate.provider_id === "string" && + typeof candidate.host === "string" && + typeof candidate.scope === "string" && + typeof candidate.provider_repository_id === "string" + ); + } + return candidate.kind === "local" && typeof candidate.path === "string"; +} + +export function normalizeLocalRepositoryPath(path: string): string { + const normalized = path.replaceAll("\\", "/"); + if (normalized === "") return ""; + const withoutTrailing = normalized.replace(/\/+$/, ""); + if (withoutTrailing === "" && normalized.startsWith("/")) return "/"; + return withoutTrailing.replace(/^\/+/, "/"); +} + +function providerTarget( + provider: string, + repositoryId: string, + host: string | undefined, + scope: string | undefined, + remoteURL: string | undefined, +): RepositoryRuleTarget { + return { + kind: "provider", + provider_id: provider, + host: + normalizeProviderHost(host) || + normalizeProviderHostFromURL(remoteURL) || + defaultProviderHost(provider), + scope: scope ?? "", + provider_repository_id: repositoryId, + }; +} + +function normalizeProviderHost(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim().replace(/\/+$/, ""); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`.replace(/\/+$/, ""); + } catch { + return trimmed; + } +} + +function normalizeProviderHostFromURL(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + return new URL(value).host; + } catch { + return undefined; + } +} + +function defaultProviderHost(provider: string): string { + switch (provider) { + case "github": + return "github.com"; + case "gitlab": + return "gitlab.com"; + case "azure_devops": + return "dev.azure.com"; + default: + return ""; + } +} diff --git a/apps/web/lib/sidebar/task-color-projection.test.ts b/apps/web/lib/sidebar/task-color-projection.test.ts new file mode 100644 index 0000000000..2a73fc9d42 --- /dev/null +++ b/apps/web/lib/sidebar/task-color-projection.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { Repository } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import { taskColorProjection } from "./task-color-projection"; + +const TASK = { + id: "task-1", + workspaceId: "workspace-1", + workflowId: "workflow-1", + workflowStepId: "step-1", + title: "Task", + position: 0, + repositories: [{ id: "link-1", repository_id: "repo-1", base_branch: "main", position: 0 }], +} as KanbanState["tasks"][number]; + +const REPOSITORY = { + id: "repo-1", + workspace_id: "workspace-1", + name: "kandev", + source_type: "github", + local_path: "", + provider: "github", + provider_repo_id: "kdlbs/kandev", + provider_host: "github.com", + provider_scope: "kdlbs", +} as Repository; + +describe("taskColorProjection", () => { + it("joins saved repository identity and workflow step color before resolving", () => { + const result = taskColorProjection(TASK, { + settings: { + enabled: true, + rules: [ + { + id: "repository-rule", + enabled: true, + condition: { + dimension: "repository", + value: { + kind: "provider", + provider_id: "github", + host: "github.com", + scope: "kdlbs", + provider_repository_id: "kdlbs/kandev", + }, + label: "kdlbs/kandev", + }, + output: { kind: "fixed", color: "cyan" }, + }, + ], + }, + repositoriesById: new Map([[REPOSITORY.id, REPOSITORY]]), + stepColorById: new Map([["step-1", "emerald"]]), + }); + + expect(result?.color).toEqual({ token: "cyan", className: "bg-cyan-500" }); + expect(result?.source.ruleId).toBe("repository-rule"); + }); + + it("uses the workflow step color when the winning output follows the step", () => { + const result = taskColorProjection(TASK, { + settings: { + enabled: true, + rules: [ + { + id: "step-rule", + enabled: true, + condition: { + dimension: "workflow_step", + value: { workspace_id: "workspace-1", step_id: "step-1" }, + label: "Review", + }, + output: { kind: "workflow_step" }, + }, + ], + }, + repositoriesById: new Map(), + stepColorById: new Map([["step-1", "#abc"]]), + }); + + expect(result?.color).toEqual({ token: "custom", style: { backgroundColor: "#abc" } }); + }); +}); diff --git a/apps/web/lib/sidebar/task-color-projection.ts b/apps/web/lib/sidebar/task-color-projection.ts new file mode 100644 index 0000000000..292aedf4fa --- /dev/null +++ b/apps/web/lib/sidebar/task-color-projection.ts @@ -0,0 +1,66 @@ +import type { Repository } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import { + repositoryIdentityForSavedRepository, + repositoryIdentityForTaskRepository, + type TaskRepositoryRuleIdentity, +} from "./repository-rule-identity"; +import { + resolveAutomaticTaskColor, + type AutomaticTaskColorResult, + type TaskColorFacts, +} from "./task-color-rules"; + +type ProjectedTask = KanbanState["tasks"][number] & { _workflowId?: string }; + +export type TaskColorProjectionContext = { + settings: SidebarTaskColorAutomation; + workspaceId?: string; + repositoriesById: ReadonlyMap; + stepColorById: ReadonlyMap; +}; + +export function taskColorFacts( + task: ProjectedTask, + context: Pick, +): TaskColorFacts { + const workspaceId = task.workspaceId ?? context.workspaceId; + const workflowId = task._workflowId ?? task.workflowId; + return { + workspaceId, + workflowId, + workflowStepId: task.workflowStepId, + workflowStepColor: context.stepColorById.get(task.workflowStepId), + state: task.state, + priority: task.priority, + origin: task.origin, + primaryExecutorProfileId: task.primaryExecutorProfileId ?? undefined, + repositories: taskRepositoryIdentities(task, workspaceId, context.repositoriesById), + }; +} + +export function taskColorProjection( + task: ProjectedTask, + context: TaskColorProjectionContext, +): AutomaticTaskColorResult | null { + return resolveAutomaticTaskColor(context.settings, taskColorFacts(task, context)); +} + +function taskRepositoryIdentities( + task: ProjectedTask, + workspaceId: string | undefined, + repositoriesById: ReadonlyMap, +): TaskRepositoryRuleIdentity[] { + const links = + task.repositories ?? (task.repositoryId ? [{ repository_id: task.repositoryId }] : []); + return links.map((link) => { + const saved = repositoriesById.get(link.repository_id); + return saved + ? repositoryIdentityForSavedRepository(saved) + : repositoryIdentityForTaskRepository({ + repository_id: link.repository_id, + workspace_id: workspaceId, + }); + }); +} diff --git a/apps/web/lib/sidebar/task-color-rules.test.ts b/apps/web/lib/sidebar/task-color-rules.test.ts new file mode 100644 index 0000000000..abb1b70744 --- /dev/null +++ b/apps/web/lib/sidebar/task-color-rules.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import { resolveAutomaticTaskColor } from "./task-color-rules"; + +const automation = (rules: SidebarTaskColorAutomation["rules"]): SidebarTaskColorAutomation => ({ + enabled: true, + rules, +}); + +const fixed = ( + id: string, + dimension: + | "task_state" + | "priority" + | "origin" + | "workflow_step" + | "repository" + | "workflow" + | "executor_profile", + value: unknown, + color: "red" | "blue" | "gray" | "cyan", + enabled = true, +) => ({ + id, + enabled, + condition: { dimension, value, label: id }, + output: { kind: "fixed" as const, color }, +}); + +describe("resolveAutomaticTaskColor", () => { + it("returns the first enabled matching rule", () => { + const result = resolveAutomaticTaskColor( + automation([ + fixed("state", "task_state", "TODO", "red"), + fixed("priority", "priority", "high", "blue"), + ]), + { state: "TODO", priority: "high", repositories: [] }, + ); + expect(result?.color).toEqual({ token: "red", className: "bg-red-500" }); + expect(result?.source.ruleId).toBe("state"); + }); + + it("matches every supported scalar dimension and normalizes a missing origin to Kanban", () => { + expect( + resolveAutomaticTaskColor(automation([fixed("priority", "priority", "critical", "blue")]), { + priority: "critical", + repositories: [], + })?.source.ruleId, + ).toBe("priority"); + expect( + resolveAutomaticTaskColor(automation([fixed("origin", "origin", "kanban", "cyan")]), { + repositories: [], + })?.source.ruleId, + ).toBe("origin"); + expect( + resolveAutomaticTaskColor( + automation([fixed("executor", "executor_profile", "profile-1", "blue")]), + { primaryExecutorProfileId: "profile-1", repositories: [] }, + )?.source.ruleId, + ).toBe("executor"); + }); + + it("uses the current workflow-step color and gray for unsupported colors", () => { + const workflowStepRule = { + id: "step", + enabled: true, + condition: { + dimension: "workflow_step" as const, + value: { workspace_id: "workspace-a", step_id: "step-1" }, + label: "Review", + }, + output: { kind: "workflow_step" as const }, + }; + expect( + resolveAutomaticTaskColor(automation([workflowStepRule]), { + workspaceId: "workspace-a", + workflowStepId: "step-1", + workflowStepColor: "#0af", + repositories: [], + }), + ).toEqual( + expect.objectContaining({ color: { token: "custom", style: { backgroundColor: "#0af" } } }), + ); + expect( + resolveAutomaticTaskColor(automation([workflowStepRule]), { + workspaceId: "workspace-a", + workflowStepId: "step-1", + workflowStepColor: "not-a-color", + repositories: [], + })?.color, + ).toEqual({ token: "gray", className: "bg-slate-500" }); + }); + + it("does not match incomplete rules or missing facts", () => { + expect( + resolveAutomaticTaskColor(automation([fixed("missing", "task_state", null, "red", false)]), { + repositories: [], + }), + ).toBeNull(); + expect( + resolveAutomaticTaskColor(automation([fixed("missing", "task_state", "TODO", "red")]), { + state: "REVIEW", + repositories: [], + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/lib/sidebar/task-color-rules.ts b/apps/web/lib/sidebar/task-color-rules.ts new file mode 100644 index 0000000000..cc9faf75b8 --- /dev/null +++ b/apps/web/lib/sidebar/task-color-rules.ts @@ -0,0 +1,107 @@ +import type { TaskOrigin, TaskPriority, TaskState } from "@/lib/types/http"; +import type { + SidebarTaskColorAutomation, + SidebarTaskColorDimension, + SidebarTaskColorRule, +} from "@/lib/task-color-automation-settings"; +import { + fixedAutomaticTaskColor, + parseWorkflowStepColor, + type TaskMarkerPresentation, +} from "@/lib/task-color-presentation"; +import { + isRepositoryRuleTarget, + repositoryRuleTargetMatches, + type TaskRepositoryRuleIdentity, +} from "./repository-rule-identity"; + +export type TaskColorFacts = { + workspaceId?: string; + workflowId?: string; + workflowStepId?: string; + workflowStepColor?: string; + state?: TaskState | string; + priority?: TaskPriority | string; + /** Undefined origins represent legacy Kanban tasks during matching. */ + origin?: TaskOrigin | string; + primaryExecutorProfileId?: string; + repositories: readonly TaskRepositoryRuleIdentity[]; +}; + +export type AutomaticTaskColorSource = { + ruleId: string; + dimension: SidebarTaskColorDimension; + label: string; + outputKind: "fixed" | "workflow_step"; + color?: string; +}; + +export type AutomaticTaskColorResult = { + color: TaskMarkerPresentation; + source: AutomaticTaskColorSource; +}; + +export function resolveAutomaticTaskColor( + settings: SidebarTaskColorAutomation, + facts: TaskColorFacts, +): AutomaticTaskColorResult | null { + if (!settings.enabled) return null; + for (const rule of settings.rules) { + if (!rule.enabled || rule.condition.value === null || rule.condition.value === undefined) { + continue; + } + if (!matchesRule(rule, facts)) continue; + const color = + rule.output.kind === "workflow_step" + ? parseWorkflowStepColor(facts.workflowStepColor) + : fixedAutomaticTaskColor(rule.output.color); + return { + color, + source: { + ruleId: rule.id, + dimension: rule.condition.dimension, + label: rule.condition.label, + outputKind: rule.output.kind, + color: rule.output.kind === "fixed" ? rule.output.color : undefined, + }, + }; + } + return null; +} + +function matchesRule(rule: SidebarTaskColorRule, facts: TaskColorFacts): boolean { + return RULE_MATCHERS[rule.condition.dimension](rule.condition.value, facts); +} + +type RuleMatcher = (value: unknown, facts: TaskColorFacts) => boolean; + +const RULE_MATCHERS: Record = { + workflow_step: (value, facts) => + matchesScopedTarget(value, facts, "step_id", facts.workflowStepId), + workflow: (value, facts) => matchesScopedTarget(value, facts, "workflow_id", facts.workflowId), + repository: (value, facts) => + isRepositoryRuleTarget(value) && + repositoryRuleTargetMatches(value, facts.workspaceId, facts.repositories), + executor_profile: (value, facts) => matchesFact(value, facts.primaryExecutorProfileId), + task_state: (value, facts) => matchesFact(value, facts.state), + priority: (value, facts) => matchesFact(value, facts.priority), + origin: (value, facts) => value === (facts.origin ?? "kanban"), +}; + +function matchesScopedTarget( + value: unknown, + facts: TaskColorFacts, + field: "step_id" | "workflow_id", + expected: string | undefined, +): boolean { + if (!isRecord(value)) return false; + return value.workspace_id === facts.workspaceId && value[field] === expected; +} + +function matchesFact(value: unknown, expected: string | undefined): boolean { + return expected !== undefined && value === expected; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 97387aff94..7da3fa6383 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -10,6 +10,13 @@ import { type KanbanTask = KanbanState["tasks"][number]; +function snapshotTaskWorkspaceId( + task: WorkflowSnapshot["tasks"][number], + fallback: string, +): string { + return task.workspace_id ?? fallback; +} + // Split out so the snapshot->task mapper (already at the complexity limit // from its long list of `??` fallbacks) doesn't need to absorb one more. function resolveAutoStartFailed(task: WorkflowSnapshot["tasks"][number]): boolean { @@ -19,6 +26,7 @@ function resolveAutoStartFailed(task: WorkflowSnapshot["tasks"][number]): boolea function primaryExecutorFields(task: Task) { return { primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorProfileId: task.primary_executor_profile_id ?? undefined, primaryExecutorType: task.primary_executor_type ?? undefined, primaryExecutorName: task.primary_executor_name ?? undefined, isRemoteExecutor: task.is_remote_executor ?? false, @@ -59,6 +67,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { const primary = primaryTaskRepository(task.repositories); return { id: task.id, + workspaceId: snapshotTaskWorkspaceId(task, snapshot.workflow.workspace_id), workflowId: snapshot.workflow.id, workflowStepId, title: task.title, @@ -72,6 +81,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { // same values so queue classification and ordering stay consistent // after a workflow switch or reconnect. priority: task.priority, + origin: task.origin, createdAt: task.created_at, wipAdmitted: task.wip_admitted, queuedForStepId: task.queued_for_step_id, @@ -95,7 +105,6 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { primaryAgentProfileId: task.primary_agent_profile_id ?? undefined, primaryAgentName: task.primary_agent_name ?? undefined, labels: parseLabels(task.labels), - origin: task.origin, primarySessionPendingAction: pickPendingAction(task.primary_session_pending_action), taskPendingAction: pickPendingAction(task.task_pending_action), foregroundActivity: task.foreground_activity ?? undefined, diff --git a/apps/web/lib/ssr/user-settings-task-colors.test.ts b/apps/web/lib/ssr/user-settings-task-colors.test.ts new file mode 100644 index 0000000000..db27b73afc --- /dev/null +++ b/apps/web/lib/ssr/user-settings-task-colors.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { mapUserSettingsData, mapUserSettingsResponse } from "./user-settings"; +import { workspaceId as toWorkspaceId } from "@/lib/types/ids"; +import type { SidebarTaskColorsApi } from "@/lib/types/http-user-settings"; + +const UPDATED_AT = "2026-01-01T00:00:00Z"; +const DEFAULT_USER_ID = "default-user"; + +describe("manual task-color settings", () => { + it("maps valid colors and preserves clear tombstones", () => { + const result = mapUserSettingsResponse({ + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + sidebar_task_colors: { + "task-red": "red", + "task-cleared": null, + "task-invalid": "gray", + } as unknown as SidebarTaskColorsApi, + updated_at: UPDATED_AT, + }, + }); + + expect(result.sidebarTaskColors).toEqual({ + "task-red": "red", + "task-cleared": null, + }); + }); + + it("keeps the current map when a partial settings update omits colors", () => { + const current = { + ...mapUserSettingsResponse(null), + sidebarTaskColors: { "task-red": "red" as const }, + }; + const result = mapUserSettingsData({}, current); + expect(result.sidebarTaskColors).toEqual({ "task-red": "red" }); + }); +}); diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 1abf2e1656..e7f92ae378 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -14,6 +14,7 @@ import { } from "./user-settings"; import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; import { workspaceId as toWorkspaceId } from "@/lib/types/ids"; +import type { SidebarTaskColorAutomation } from "@/lib/types/http-user-settings"; const UPDATED_AT = "2026-01-01T00:00:00Z"; const DEFAULT_USER_ID = "default-user"; @@ -416,6 +417,34 @@ describe("Azure DevOps browse preference mapping", () => { }); }); +describe("automatic task-color hydration", () => { + it("maps the portable automatic task-color rules", () => { + const automation: SidebarTaskColorAutomation = { + enabled: true, + rules: [ + { + id: "blocked", + enabled: true, + condition: { dimension: "task_state", value: "BLOCKED", label: "Blocked" }, + output: { kind: "fixed", color: "red" }, + }, + ], + }; + + const result = mapUserSettingsResponse({ + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + sidebar_task_color_automation: automation, + updated_at: UPDATED_AT, + }, + }); + + expect(result.sidebarTaskColorAutomation).toEqual(automation); + }); +}); + describe("mapUserSettingsResponse", () => { it("maps the portable task-list details preference and defaults it to false", () => { expect(mapUserSettingsResponse(null).tasksListShowDetails).toBe(false); diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index fbbc0bab47..b84dddba2e 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -14,6 +14,8 @@ import { } from "@/lib/state/slices/ui/thread-view-builtins"; import { type UserSettingsState } from "@/lib/state/slices/settings/types"; import type { SidebarTaskPrefsApi, UserSettings, UserSettingsResponse } from "@/lib/types/http"; +import { parseSidebarTaskColorAutomation } from "@/lib/task-color-automation-settings"; +import { parseSidebarTaskColors } from "@/lib/task-colors"; import type { LspStatusLocation, LastSeenDisplay, @@ -68,6 +70,8 @@ export function createDefaultUserSettings(): UserSettingsState { threadActiveViewId: DEFAULT_THREAD_VIEW_ID, threadViewDraft: null, sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + sidebarTaskColorAutomation: parseSidebarTaskColorAutomation(undefined), + sidebarTaskColors: {}, taskCreateLastUsed: { repositoryId: null, branch: null, @@ -324,6 +328,16 @@ export function buildCoreFields( current.sidebarTaskPrefs, parseSidebarTaskPrefs, ), + sidebarTaskColorAutomation: mapDefined( + s.sidebar_task_color_automation, + current.sidebarTaskColorAutomation, + parseSidebarTaskColorAutomation, + ), + sidebarTaskColors: mapDefined( + s.sidebar_task_colors, + current.sidebarTaskColors, + parseSidebarTaskColors, + ), taskCreateLastUsed: mapDefined( s.task_create_last_used, current.taskCreateLastUsed, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index f89cbe5928..08f2f5ed28 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -1,6 +1,7 @@ import type { ForegroundActivity, TaskPendingAction, + TaskOrigin, TaskPriority, TaskState as TaskStatus, } from "@/lib/types/http"; @@ -72,6 +73,7 @@ export type KanbanState = { description?: string; autopilot?: boolean; priority?: TaskPriority; + origin?: TaskOrigin | string; position: number; state?: TaskStatus; /** Primary repository id (lowest position). Kept for backwards compat. */ @@ -119,12 +121,12 @@ export type KanbanState = { sessionCount?: number | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; primaryExecutorId?: string | null; + primaryExecutorProfileId?: string | null; primaryExecutorType?: string | null; primaryExecutorName?: string | null; primaryAgentProfileId?: string | null; primaryAgentName?: string | null; labels?: string[]; - origin?: string; isRemoteExecutor?: boolean; /** Human assignee (user id). Independent of any agent assignment. */ assigneeUserId?: string; diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index b32712a628..fea269ba8c 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -9,6 +9,7 @@ import type { Executor, NotificationProvider, SavedLayout, + SidebarTaskColorAutomation, ToolStatus, LspStatusLocation, LastSeenDisplay, @@ -28,6 +29,7 @@ import type { AgentProfileRecentUseState, } from "@/lib/agent-profile-recent-use"; import type { AgentProfileRecentUseContext } from "@/lib/types/http-agent-profile-recent-use"; +import type { TaskColor } from "@/lib/task-colors"; export type { AgentProfileRecentUseRecord, @@ -434,6 +436,8 @@ export type UserSettingsState = { threadActiveViewId: string | null; threadViewDraft: ThreadViewDraft | null; sidebarTaskPrefs: SidebarTaskPrefsState; + sidebarTaskColorAutomation: SidebarTaskColorAutomation; + sidebarTaskColors: Record; taskCreateLastUsed: TaskCreateLastUsedState; jiraSavedViews: unknown; jiraTaskPresets: unknown; diff --git a/apps/web/lib/task-color-automation-settings.test.ts b/apps/web/lib/task-color-automation-settings.test.ts new file mode 100644 index 0000000000..7fb23163a1 --- /dev/null +++ b/apps/web/lib/task-color-automation-settings.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION, + parseSidebarTaskColorAutomation, + type SidebarTaskColorAutomation, +} from "./task-color-automation-settings"; + +const fixedRule = { + id: "blocked", + enabled: true, + condition: { dimension: "task_state", value: "BLOCKED", label: "Blocked" }, + output: { kind: "fixed", color: "red" }, +} as const; + +describe("parseSidebarTaskColorAutomation", () => { + it("uses a disabled empty default when the setting is missing or malformed", () => { + expect(parseSidebarTaskColorAutomation(undefined)).toEqual( + DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION, + ); + expect(parseSidebarTaskColorAutomation({ enabled: true, rules: "not-an-array" })).toEqual( + DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION, + ); + expect( + parseSidebarTaskColorAutomation({ + enabled: true, + rules: [{ ...fixedRule, condition: { ...fixedRule.condition, value: null } }], + }), + ).toEqual(DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION); + }); + + it("preserves disabled incomplete rules and complete replacement order", () => { + const value: SidebarTaskColorAutomation = { + enabled: true, + rules: [ + fixedRule, + { + id: "unfinished", + enabled: false, + condition: { dimension: "repository", value: null, label: "Missing repository" }, + output: { kind: "fixed", color: "cyan" }, + }, + ], + }; + expect(parseSidebarTaskColorAutomation(value)).toEqual(value); + }); + + it("rejects duplicate ids, unsupported dimensions, and unsupported colors", () => { + for (const invalid of [ + { enabled: true, rules: [{ ...fixedRule }, { ...fixedRule }] }, + { + enabled: true, + rules: [{ ...fixedRule, condition: { dimension: "future", value: "x", label: "x" } }], + }, + { enabled: true, rules: [{ ...fixedRule, output: { kind: "fixed", color: "teal" } }] }, + ]) { + expect(parseSidebarTaskColorAutomation(invalid)).toEqual( + DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION, + ); + } + }); +}); diff --git a/apps/web/lib/task-color-automation-settings.ts b/apps/web/lib/task-color-automation-settings.ts new file mode 100644 index 0000000000..181a422b8c --- /dev/null +++ b/apps/web/lib/task-color-automation-settings.ts @@ -0,0 +1,216 @@ +import type { + FixedAutomaticTaskColor, + SidebarTaskColorAutomation, + SidebarTaskColorDimension, + SidebarTaskColorRepositoryTarget, + SidebarTaskColorRule, +} from "@/lib/types/http-user-settings"; + +export type { + FixedAutomaticTaskColor, + SidebarTaskColorAutomation, + SidebarTaskColorDimension, + SidebarTaskColorRepositoryTarget, + SidebarTaskColorRule, +}; + +export const AUTOMATIC_TASK_COLOR_DIMENSIONS: readonly SidebarTaskColorDimension[] = [ + "workflow_step", + "repository", + "workflow", + "executor_profile", + "task_state", + "priority", + "origin", +]; + +export const FIXED_AUTOMATIC_TASK_COLORS: readonly FixedAutomaticTaskColor[] = [ + "gray", + "red", + "orange", + "yellow", + "green", + "cyan", + "blue", + "indigo", + "purple", + "pink", +]; + +export const MAX_AUTOMATIC_TASK_COLOR_RULES = 50; + +export const DEFAULT_SIDEBAR_TASK_COLOR_AUTOMATION: SidebarTaskColorAutomation = { + enabled: false, + rules: [], +}; + +/** Returns a fresh disabled value so callers cannot mutate the shared default. */ +export function createDefaultSidebarTaskColorAutomation(): SidebarTaskColorAutomation { + return { enabled: false, rules: [] }; +} + +/** + * Normalizes the portable rule set at every settings-ingestion boundary. + * Invalid data is isolated to this preference and becomes a disabled empty + * value, while disabled incomplete rules remain editable and are preserved. + */ +export function parseSidebarTaskColorAutomation(value: unknown): SidebarTaskColorAutomation { + if (!isRecord(value) || typeof value.enabled !== "boolean" || !Array.isArray(value.rules)) { + return createDefaultSidebarTaskColorAutomation(); + } + if (value.rules.length > MAX_AUTOMATIC_TASK_COLOR_RULES) { + return createDefaultSidebarTaskColorAutomation(); + } + + const rules: SidebarTaskColorRule[] = []; + const ids = new Set(); + for (const rawRule of value.rules) { + const rule = parseRule(rawRule, ids); + if (!rule) return createDefaultSidebarTaskColorAutomation(); + rules.push(rule); + } + return { enabled: value.enabled, rules }; +} + +function parseRule(value: unknown, ids: Set): SidebarTaskColorRule | null { + if (!isRecord(value)) return null; + const identity = parseRuleIdentity(value, ids); + if (!identity) return null; + const condition = parseCondition(value, identity.enabled); + if (!condition) return null; + const output = parseOutput(value, condition.dimension); + if (!output) return null; + return { ...identity, condition, output }; +} + +function parseRuleIdentity( + value: Record, + ids: Set, +): { id: string; enabled: boolean } | null { + if (typeof value.id !== "string" || !value.id) return null; + if (byteLength(value.id) > 64 || ids.has(value.id) || typeof value.enabled !== "boolean") { + return null; + } + ids.add(value.id); + return { id: value.id, enabled: value.enabled }; +} + +function parseCondition( + value: Record, + enabled: boolean, +): SidebarTaskColorRule["condition"] | null { + if (!isRecord(value.condition) || typeof value.condition.dimension !== "string") return null; + const dimension = value.condition.dimension as SidebarTaskColorDimension; + if (!isDimension(dimension)) return null; + const label = value.condition.label ?? ""; + if (typeof label !== "string" || Array.from(label).length > 200) return null; + const target = value.condition.value; + if (target !== null && target !== undefined && !isTarget(dimension, target)) return null; + if ((target === null || target === undefined) && enabled) return null; + return { dimension, value: target ?? null, label }; +} + +function parseOutput( + value: Record, + dimension: SidebarTaskColorDimension, +): SidebarTaskColorRule["output"] | null { + if (!isRecord(value.output) || typeof value.output.kind !== "string") return null; + if (value.output.kind === "fixed" && isFixedColor(value.output.color)) { + return { kind: "fixed", color: value.output.color }; + } + if ( + value.output.kind === "workflow_step" && + dimension === "workflow_step" && + value.output.color === undefined + ) { + return { kind: "workflow_step" }; + } + return null; +} + +type ScalarTargetDimension = "executor_profile" | "task_state" | "priority" | "origin"; +type StructuredTargetDimension = Exclude; + +const SCALAR_TARGET_DIMENSIONS: readonly ScalarTargetDimension[] = [ + "executor_profile", + "task_state", + "priority", + "origin", +]; + +const STRUCTURED_TARGET_VALIDATORS: Record boolean> = + { + workflow_step: (value) => isBoundedExactTarget(value, ["workspace_id", "step_id"], 512), + workflow: (value) => isBoundedExactTarget(value, ["workspace_id", "workflow_id"], 512), + repository: isRepositoryTarget, + }; + +function isTarget(dimension: SidebarTaskColorDimension, value: unknown): boolean { + if (isScalarTargetDimension(dimension)) return isBoundedString(value, 512); + return STRUCTURED_TARGET_VALIDATORS[dimension](value); +} + +function isScalarTargetDimension( + dimension: SidebarTaskColorDimension, +): dimension is ScalarTargetDimension { + return SCALAR_TARGET_DIMENSIONS.includes(dimension as ScalarTargetDimension); +} + +function isRepositoryTarget(value: unknown): boolean { + if (!isRecord(value) || typeof value.kind !== "string") return false; + if (value.kind === "workspace") { + return isBoundedExactTarget(value, ["kind", "workspace_id", "repository_id"], 512); + } + if (value.kind === "provider") { + return isBoundedExactTarget( + value, + ["kind", "provider_id", "host", "scope", "provider_repository_id"], + 512, + ); + } + return value.kind === "local" && isBoundedExactTarget(value, ["kind", "path"], 4096); +} + +function isBoundedString(value: unknown, maxBytes: number): value is string { + return typeof value === "string" && value.length > 0 && byteLength(value) <= maxBytes; +} + +function isBoundedExactTarget( + value: unknown, + fields: string[], + maxBytes: number, +): value is Record { + return ( + isRecord(value) && + hasExactStringFields(value, fields) && + Object.values(value).every((field) => byteLength(field) <= maxBytes) + ); +} + +function hasExactStringFields( + value: Record, + fields: string[], +): value is Record { + return ( + Object.keys(value).length === fields.length && + fields.every((field) => typeof value[field] === "string" && value[field].length > 0) + ); +} + +function isDimension(value: string): value is SidebarTaskColorDimension { + return (AUTOMATIC_TASK_COLOR_DIMENSIONS as readonly string[]).includes(value); +} + +function isFixedColor(value: unknown): value is FixedAutomaticTaskColor { + return ( + typeof value === "string" && (FIXED_AUTOMATIC_TASK_COLORS as readonly string[]).includes(value) + ); +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/task-color-presentation.test.ts b/apps/web/lib/task-color-presentation.test.ts new file mode 100644 index 0000000000..74fd3f9a0e --- /dev/null +++ b/apps/web/lib/task-color-presentation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + fixedAutomaticTaskColor, + parseWorkflowStepColor, + taskColorPresentation, +} from "./task-color-presentation"; + +describe("task color presentation", () => { + it("maps fixed automatic colors through the static safe registry", () => { + expect(fixedAutomaticTaskColor("gray")).toEqual({ + token: "gray", + className: "bg-slate-500", + }); + expect(fixedAutomaticTaskColor("cyan")).toEqual({ + token: "cyan", + className: "bg-cyan-500", + }); + }); + + it("maps workflow catalog and legacy classes without rendering arbitrary classes", () => { + expect(parseWorkflowStepColor("emerald")).toEqual(taskColorPresentation("green")); + expect(parseWorkflowStepColor("bg-neutral-400")).toEqual(taskColorPresentation("gray")); + expect(parseWorkflowStepColor("bg-emerald-500")).toEqual(taskColorPresentation("green")); + expect(parseWorkflowStepColor("bg-[url(http://evil)]")).toEqual(taskColorPresentation("gray")); + }); + + it("accepts strict hex values as inline presentation styles", () => { + expect(parseWorkflowStepColor("#0af")).toEqual({ + token: "custom", + style: { backgroundColor: "#0af" }, + }); + expect(parseWorkflowStepColor("#00aaff88")).toEqual({ + token: "custom", + style: { backgroundColor: "#00aaff88" }, + }); + expect(parseWorkflowStepColor("#00afg0")).toEqual(taskColorPresentation("gray")); + }); +}); diff --git a/apps/web/lib/task-color-presentation.ts b/apps/web/lib/task-color-presentation.ts new file mode 100644 index 0000000000..d31e6579cb --- /dev/null +++ b/apps/web/lib/task-color-presentation.ts @@ -0,0 +1,103 @@ +import type { FixedAutomaticTaskColor } from "@/lib/task-color-automation-settings"; +import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; + +export type TaskMarkerPresentation = + | { token: FixedAutomaticTaskColor; className: string } + | { token: "custom"; style: { backgroundColor: string } }; + +export type ManualTaskColorPresentation = { token: TaskColor; className: string }; + +export function manualTaskColorPresentation( + taskColor: TaskColor | null, +): ManualTaskColorPresentation | null { + if (!taskColor) return null; + return { token: taskColor, className: TASK_COLOR_BAR_CLASS[taskColor] }; +} + +export function resolveTaskItemColor( + automaticColor: TaskMarkerPresentation | undefined, + manualColor: ManualTaskColorPresentation | null, +): TaskMarkerPresentation | ManualTaskColorPresentation | null { + return automaticColor ?? manualColor; +} + +const AUTOMATIC_TASK_COLOR_BAR_CLASS: Record = { + gray: "bg-slate-500", + red: "bg-red-500", + orange: "bg-orange-500", + yellow: "bg-yellow-500", + green: "bg-green-500", + cyan: "bg-cyan-500", + blue: "bg-blue-500", + indigo: "bg-indigo-500", + purple: "bg-purple-500", + pink: "bg-pink-500", +}; + +const WORKFLOW_COLOR_TOKEN: Record = { + gray: "gray", + grey: "gray", + slate: "gray", + zinc: "gray", + neutral: "gray", + stone: "gray", + red: "red", + orange: "orange", + amber: "yellow", + yellow: "yellow", + lime: "green", + green: "green", + emerald: "green", + teal: "cyan", + cyan: "cyan", + sky: "blue", + blue: "blue", + indigo: "indigo", + violet: "purple", + purple: "purple", + fuchsia: "pink", + pink: "pink", +}; + +const WORKFLOW_CLASS_TOKEN: Record = { + "bg-slate-500": "gray", + "bg-gray-500": "gray", + "bg-zinc-500": "gray", + "bg-neutral-400": "gray", + "bg-neutral-500": "gray", + "bg-stone-500": "gray", + "bg-red-500": "red", + "bg-orange-500": "orange", + "bg-amber-500": "yellow", + "bg-yellow-500": "yellow", + "bg-lime-500": "green", + "bg-green-500": "green", + "bg-emerald-500": "green", + "bg-teal-500": "cyan", + "bg-cyan-500": "cyan", + "bg-sky-500": "blue", + "bg-blue-500": "blue", + "bg-indigo-500": "indigo", + "bg-violet-500": "purple", + "bg-purple-500": "purple", + "bg-fuchsia-500": "pink", + "bg-pink-500": "pink", +}; + +export function taskColorPresentation(token: FixedAutomaticTaskColor): TaskMarkerPresentation { + return { token, className: AUTOMATIC_TASK_COLOR_BAR_CLASS[token] }; +} + +export function fixedAutomaticTaskColor(token: FixedAutomaticTaskColor): TaskMarkerPresentation { + return taskColorPresentation(token); +} + +/** Maps persisted workflow color data to a safe marker presentation. */ +export function parseWorkflowStepColor(value: string | undefined): TaskMarkerPresentation { + if (!value) return taskColorPresentation("gray"); + const hex = value.match(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/); + if (hex) return { token: "custom", style: { backgroundColor: hex[0] } }; + + const token = WORKFLOW_CLASS_TOKEN[value] ?? WORKFLOW_COLOR_TOKEN[value.toLowerCase()]; + return token ? taskColorPresentation(token) : taskColorPresentation("gray"); +} diff --git a/apps/web/lib/task-colors.test.ts b/apps/web/lib/task-colors.test.ts index 8dc3e9d652..3db071dbbf 100644 --- a/apps/web/lib/task-colors.test.ts +++ b/apps/web/lib/task-colors.test.ts @@ -1,75 +1,42 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - getTaskColor, - setTaskColor, + clearLegacyTaskColors, + readLegacyTaskColors, TASK_COLORS, TASK_COLOR_BAR_CLASS, TASK_COLOR_LABEL_KEYS, - TASK_COLORS_CHANGED_EVENT, TASK_COLORS_STORAGE_KEY, + parseSidebarTaskColors, } from "./task-colors"; import { i18n, t } from "@/lib/i18n"; -describe("task colors storage", () => { +describe("legacy task colors migration input", () => { beforeEach(() => { window.localStorage.clear(); - // Synthesize a cross-tab storage event so the module-level cache invalidates, - // matching how a real browser would notify other tabs after localStorage.clear(). - window.dispatchEvent(new StorageEvent("storage", { key: null })); }); - it("returns null when no color is stored", () => { - expect(getTaskColor("task-1")).toBeNull(); - }); - - it("stores and reads a color", () => { - setTaskColor("task-1", "blue"); - expect(getTaskColor("task-1")).toBe("blue"); - }); - - it("removes a color when set to null", () => { - setTaskColor("task-1", "blue"); - setTaskColor("task-1", null); - expect(getTaskColor("task-1")).toBeNull(); - }); - - it("ignores invalid colors loaded from storage", () => { + it("reads valid legacy colors without treating them as server values", () => { window.localStorage.setItem( TASK_COLORS_STORAGE_KEY, - JSON.stringify({ "task-1": "fuchsia", "task-2": "red" }), + JSON.stringify({ + "task-1": "red", + "task-cleared": null, + "task-automatic-gray": "gray", + "task-2": "blue", + }), ); - expect(getTaskColor("task-1")).toBeNull(); - expect(getTaskColor("task-2")).toBe("red"); + expect(readLegacyTaskColors()).toEqual({ "task-1": "red", "task-2": "blue" }); }); - it("returns null on malformed storage", () => { + it("returns no values for malformed legacy storage", () => { window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, "{not json"); - expect(getTaskColor("task-1")).toBeNull(); + expect(readLegacyTaskColors()).toEqual({}); }); - it("dispatches a change event when a color is set", () => { - const listener = vi.fn(); - window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); - setTaskColor("task-1", "green"); - expect(listener).toHaveBeenCalledTimes(1); - window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); - }); - - it("does not dispatch when setting the same color twice", () => { - setTaskColor("task-1", "green"); - const listener = vi.fn(); - window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); - setTaskColor("task-1", "green"); - expect(listener).not.toHaveBeenCalled(); - window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); - }); - - it("does not dispatch when clearing a non-existent color", () => { - const listener = vi.fn(); - window.addEventListener(TASK_COLORS_CHANGED_EVENT, listener); - setTaskColor("task-1", null); - expect(listener).not.toHaveBeenCalled(); - window.removeEventListener(TASK_COLORS_CHANGED_EVENT, listener); + it("removes the legacy key only when migration asks for removal", () => { + window.localStorage.setItem(TASK_COLORS_STORAGE_KEY, JSON.stringify({ "task-1": "red" })); + clearLegacyTaskColors(); + expect(window.localStorage.getItem(TASK_COLORS_STORAGE_KEY)).toBeNull(); }); }); @@ -101,3 +68,25 @@ describe("TASK_COLOR_LABEL_KEYS", () => { } }); }); + +describe("backend manual task-color map parsing", () => { + it("keeps valid colors and tombstones while dropping malformed entries", () => { + expect( + parseSidebarTaskColors({ + "task-red": "red", + "task-cleared": null, + "task-automatic-gray": "gray", + "": "blue", + "task-object": {}, + }), + ).toEqual({ "task-red": "red", "task-cleared": null }); + }); + + it("caps task IDs by UTF-8 byte length and entry count", () => { + expect(parseSidebarTaskColors({ ["é".repeat(65)]: "red" })).toEqual({}); + const input = Object.fromEntries( + Array.from({ length: 10_001 }, (_, index) => [`task-${index}`, "red"]), + ); + expect(Object.keys(parseSidebarTaskColors(input))).toHaveLength(10_000); + }); +}); diff --git a/apps/web/lib/task-colors.ts b/apps/web/lib/task-colors.ts index 9304d006e1..3328394568 100644 --- a/apps/web/lib/task-colors.ts +++ b/apps/web/lib/task-colors.ts @@ -1,4 +1,4 @@ -import { getLocalStorage, setLocalStorage } from "./local-storage"; +import { getLocalStorage, removeLocalStorage } from "./local-storage"; export const TASK_COLORS = ["red", "orange", "yellow", "green", "blue", "purple", "pink"] as const; @@ -32,56 +32,50 @@ export const TASK_COLOR_LABEL_KEYS: Record = { }; export const TASK_COLORS_STORAGE_KEY = "kandev.taskColors"; -export const TASK_COLORS_CHANGED_EVENT = "kandev:task-colors-changed"; +export const MAX_TASK_COLOR_TASK_ID_BYTES = 128; +export const MAX_TASK_COLOR_MAP_ENTRIES = 10_000; function isTaskColor(value: unknown): value is TaskColor { return typeof value === "string" && (TASK_COLORS as readonly string[]).includes(value); } -let cachedMap: Record | null = null; - -if (typeof window !== "undefined") { - window.addEventListener("storage", (e) => { - if (e.key === null || e.key === TASK_COLORS_STORAGE_KEY) cachedMap = null; - }); -} - -function readAll(): Record { - if (cachedMap) return cachedMap; - const raw = getLocalStorage>(TASK_COLORS_STORAGE_KEY, {}); - const out: Record = {}; - if (raw && typeof raw === "object") { - for (const [taskId, color] of Object.entries(raw)) { - if (isTaskColor(color)) out[taskId] = color; +/** Parses the backend manual-color map and drops malformed entries. */ +export function parseSidebarTaskColors(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + let entryCount = 0; + for (const [taskId, color] of Object.entries(value)) { + if (entryCount >= MAX_TASK_COLOR_MAP_ENTRIES) break; + if (!isValidTaskColorTaskId(taskId)) continue; + if (color === null || isTaskColor(color)) { + result[taskId] = color; + entryCount += 1; } } - cachedMap = out; - return out; + return result; } -function writeAll(map: Record): void { - cachedMap = map; - setLocalStorage(TASK_COLORS_STORAGE_KEY, map); - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent(TASK_COLORS_CHANGED_EVENT)); +function isValidTaskColorTaskId(taskId: string): boolean { + if (!taskId) return false; + try { + const encoded = encodeURIComponent(taskId).replace(/%[0-9a-f]{2}/gi, "_"); + return encoded.length <= MAX_TASK_COLOR_TASK_ID_BYTES; + } catch { + return false; } } -export function getTaskColor(taskId: string): TaskColor | null { - if (!taskId) return null; - return readAll()[taskId] ?? null; +/** Reads legacy browser colors as migration input only. */ +export function readLegacyTaskColors(): Record { + const parsed = parseSidebarTaskColors(getLocalStorage(TASK_COLORS_STORAGE_KEY, {})); + const result: Record = {}; + for (const [taskId, color] of Object.entries(parsed)) { + if (color !== null) result[taskId] = color; + } + return result; } -export function setTaskColor(taskId: string, color: TaskColor | null): void { - if (!taskId) return; - const all = readAll(); - if (color === null) { - if (!(taskId in all)) return; - const next = { ...all }; - delete next[taskId]; - writeAll(next); - } else { - if (all[taskId] === color) return; - writeAll({ ...all, [taskId]: color }); - } +/** Removes the legacy browser key after a complete migration. */ +export function clearLegacyTaskColors(): void { + removeLocalStorage(TASK_COLORS_STORAGE_KEY); } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index b142450340..849d2ae63f 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -118,6 +118,7 @@ export type TaskEventPayload = { active_subagent_count?: number; session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; + primary_executor_profile_id?: string | null; archived_at?: string | null; updated_at?: string; created_at?: string; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index f79886ade2..bdd96a04c5 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -77,6 +77,63 @@ export type ThreadViewDraftApi = { sort: ThreadViewSortApi; max_columns: number | null; }; +export type SidebarTaskColorDimension = + | "workflow_step" + | "repository" + | "workflow" + | "executor_profile" + | "task_state" + | "priority" + | "origin"; + +export type FixedAutomaticTaskColor = + | "gray" + | "red" + | "orange" + | "yellow" + | "green" + | "cyan" + | "blue" + | "indigo" + | "purple" + | "pink"; + +export type SidebarTaskColorRepositoryTarget = + | { kind: "workspace"; workspace_id: string; repository_id: string } + | { + kind: "provider"; + provider_id: string; + host: string; + scope: string; + provider_repository_id: string; + } + | { kind: "local"; path: string }; + +export type SidebarTaskColorRule = { + id: string; + enabled: boolean; + condition: { + dimension: SidebarTaskColorDimension; + value: unknown; + label: string; + }; + output: { kind: "fixed"; color: FixedAutomaticTaskColor } | { kind: "workflow_step" }; +}; + +export type SidebarTaskColorAutomation = { + enabled: boolean; + rules: SidebarTaskColorRule[]; +}; + +/** User-settings wire alias kept explicit for API and boot-payload callers. */ +export type SidebarTaskColorAutomationApi = SidebarTaskColorAutomation; + +export type SidebarTaskColor = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink"; +export type SidebarTaskColorsApi = Record; +export type SidebarTaskColorPatchApi = { + colors: SidebarTaskColorsApi; + if_missing: boolean; +}; export type TaskCreateLastUsedApi = { repository_id?: string; @@ -132,6 +189,8 @@ export type UserSettings = { thread_active_view_id?: string; thread_view_draft?: ThreadViewDraftApi | null; sidebar_task_prefs?: SidebarTaskPrefsApi; + sidebar_task_color_automation?: SidebarTaskColorAutomationApi; + sidebar_task_colors?: SidebarTaskColorsApi; task_create_last_used?: TaskCreateLastUsedApi; jira_saved_views?: unknown; jira_task_presets?: unknown; @@ -203,6 +262,8 @@ export type UserSettingsUpdatePayload = { thread_active_view_id?: string; thread_view_draft?: ThreadViewDraftApi | null; sidebar_task_prefs?: SidebarTaskPrefsApi; + sidebar_task_color_automation?: SidebarTaskColorAutomationApi; + sidebar_task_color_patch?: SidebarTaskColorPatchApi; task_create_last_used?: TaskCreateLastUsedApi; jira_saved_views?: unknown[] | null; jira_task_presets?: unknown[] | null; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ab1e2b4d53..9b524f1d5e 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -29,6 +29,15 @@ export type { SidebarViewApi, SidebarViewDraftApi, SidebarTaskPrefsApi, + SidebarTaskColorAutomation, + SidebarTaskColorAutomationApi, + SidebarTaskColor, + SidebarTaskColorsApi, + SidebarTaskColorPatchApi, + SidebarTaskColorDimension, + SidebarTaskColorRepositoryTarget, + SidebarTaskColorRule, + FixedAutomaticTaskColor, TaskCreateLastUsedApi, AppStatusBarOrderApi, ThreadTaskScopeApi, @@ -436,6 +445,7 @@ export type Task = ActiveSubagentCountFields & { session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; primary_executor_id?: string | null; + primary_executor_profile_id?: string | null; primary_executor_type?: ExecutorType | null; primary_executor_name?: string | null; primary_agent_name?: string | null; diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 6fae6498d7..a4d449fb32 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -50,6 +50,9 @@ function preservePrimaryExecutorFields( if (!hasPayloadField(payload, "primary_executor_id")) { merged.primaryExecutorId = existing.primaryExecutorId; } + if (!hasPayloadField(payload, "primary_executor_profile_id")) { + merged.primaryExecutorProfileId = existing.primaryExecutorProfileId; + } if (!hasPayloadField(payload, "primary_executor_type")) { merged.primaryExecutorType = existing.primaryExecutorType; } diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 1b9e375a87..91e70057b6 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -180,6 +180,26 @@ describe("session hostname resolution websocket sync", () => { }); }); +describe("manual task-color websocket sync", () => { + it("applies the normalized server map, including clear tombstones", () => { + const store = makeStore(); + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + revision: 1, + sidebar_task_colors: { + "task-red": "red", + "task-cleared": null, + }, + }), + ); + + expect(store.getState().userSettings.sidebarTaskColors).toEqual({ + "task-red": "red", + "task-cleared": null, + }); + }); +}); + describe("last seen display websocket sync", () => { it("applies a relative value and normalizes unknown values", () => { const store = makeStore(); diff --git a/apps/web/scripts/lib/zh-hant-overrides.json b/apps/web/scripts/lib/zh-hant-overrides.json index d9ce10f1a4..761008dbfd 100644 --- a/apps/web/scripts/lib/zh-hant-overrides.json +++ b/apps/web/scripts/lib/zh-hant-overrides.json @@ -1,6 +1,9 @@ { "web": { "zh-tw": { + "agents": { + "dynamicProfileSettings": "動態設定檔設定" + }, "settings": { "preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式", "preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。" @@ -29,6 +32,9 @@ } }, "zh-hk": { + "agents": { + "dynamicProfileSettings": "動態設定檔設定" + }, "settings": { "preventAutoStartAgentOnOpen": "開啟任務時不自動啓動代理程式", "preventAutoStartAgentOnOpenHelp": "重新啓動後,或從工作流程最後一步開啟任務時,不會自動啓動代理程式,改為顯示「啓動代理程式」按鈕。" diff --git a/apps/web/src/app-shell.test.tsx b/apps/web/src/app-shell.test.tsx index b1f19f7430..cff3110b79 100644 --- a/apps/web/src/app-shell.test.tsx +++ b/apps/web/src/app-shell.test.tsx @@ -41,6 +41,9 @@ vi.mock("@/components/workspace-scope-provider", () => ({ WorkspaceScopeProvider: mocks.passthrough, })); vi.mock("@/components/ws-connector", () => ({ WebSocketConnector: () => null })); +vi.mock("@/hooks/use-task-color-migration", () => ({ + useTaskColorMigration: () => undefined, +})); vi.mock("@/lib/commands/command-registry", () => ({ CommandRegistryProvider: mocks.passthrough, })); diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index fe532bc970..7c530ff6c5 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -21,6 +21,7 @@ import { ToastProvider } from "@/components/toast-provider"; import { WorkspaceScopeProvider } from "@/components/workspace-scope-provider"; import { WebSocketConnector } from "@/components/ws-connector"; import { useWindowControlsOverlay } from "@/hooks/use-window-controls-overlay"; +import { useTaskColorMigration } from "@/hooks/use-task-color-migration"; import { CommandRegistryProvider } from "@/lib/commands/command-registry"; import { I18nProvider } from "@/lib/i18n/provider"; import { Toaster as SonnerToaster } from "@kandev/ui/sonner"; @@ -51,6 +52,8 @@ function AppToaster() { } export function AppShell({ children }: AppShellProps) { + useTaskColorMigration(); + const titlebar = useWindowControlsOverlay(); const shellStyle = { "--titlebar-area-x": `${titlebar.x}px`, diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index a92c3e7242..b71d427401 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -127,6 +127,62 @@ "authenticationRequiredPleaseLogInVia": "Authentication required; please log in via the terminal", "autoMarkReviewedOnScroll": "Auto-mark reviewed on scroll", "autoStart": "Auto-start", + "automaticColorSource": "Automatic: {{rule}}. Manual color is hidden while this rule matches.", + "automaticColors": "Automatic colors", + "automaticColorsAddRule": "Add rule", + "automaticColorsBackToRule": "Back to rule", + "automaticColorsCompleteRule": "Complete this rule before enabling it.", + "automaticColorsCondition": "Condition", + "automaticColorsDimensionExecutorProfile": "Executor profile", + "automaticColorsDimensionOrigin": "Origin", + "automaticColorsDimensionPriority": "Priority", + "automaticColorsDimensionRepository": "Repository", + "automaticColorsDimensionTaskState": "Task state", + "automaticColorsDimensionWorkflow": "Workflow", + "automaticColorsDimensionWorkflowStep": "Workflow step", + "automaticColorsEnable": "Enable rule", + "automaticColorsEnabled": "Automatic colors", + "automaticColorsFixedColor": "Fixed color", + "automaticColorsHelp": "Rules run when tasks appear and whenever their workflow step, repository, workflow, executor profile, state, priority, or origin changes. Changing rule order, output, or enabled state also updates colors.", + "automaticColorsHelpLabel": "When automatic colors apply", + "automaticColorsIncomplete": "Incomplete", + "automaticColorsMoveDown": "Move rule down", + "automaticColorsMoveUp": "Move rule up", + "automaticColorsNoRepositories": "No repositories found.", + "automaticColorsOutput": "Output", + "automaticColorsPersonalGlobal": "Personal setting. Applies across sidebar views and workspaces.", + "automaticColorsPrecedence": "The first matching rule wins. Manual colors remain as fallback.", + "automaticColorsRefreshRepositories": "Refresh repositories", + "automaticColorsRemoveRule": "Remove rule", + "automaticColorsRepositoryError": "Some repository sources could not be loaded.", + "automaticColorsRepositoryLocal": "Discovered local repositories", + "automaticColorsRepositoryRemote": "Remote repositories", + "automaticColorsRepositoryUnavailable": "Unavailable target", + "automaticColorsRepositoryWorkspace": "Workspace repositories", + "automaticColorsReorderHandle": "Reorder rule {{number}}", + "automaticColorsReorderable": "sortable rule", + "automaticColorsRule": "Rule {{number}}", + "automaticColorsRuleCount_one": "{{count}} rule", + "automaticColorsRuleCount_other": "{{count}} rules", + "automaticColorsRuleLimit": "You can add up to 50 rules.", + "automaticColorsSaveError": "Could not save automatic colors.", + "manualColorSaveError": "Could not save the task color.", + "automaticColorsSearchRepositories": "Search repositories", + "automaticColorsSelectRepository": "Select repository", + "automaticColorsSelectTarget": "Select a target", + "automaticColorsSummaryOff": "Off", + "automaticColorsSummaryRules_one": "{{count}} enabled rule", + "automaticColorsSummaryRules_other": "{{count}} enabled rules", + "automaticColorsTiming": "Rules apply to existing and new sidebar tasks. Colors update when task details or rules change.", + "automaticColorsUnavailable": "Unavailable", + "automaticColorsUseStepColor": "Use step color", + "automaticColorsOriginAgentCreated": "Agent-created", + "automaticColorsOriginAutomationRun": "Automation run", + "automaticColorsOriginAutomationTask": "Automation task", + "automaticColorsOriginKanban": "Kanban", + "automaticColorsOriginManual": "Manual", + "automaticColorsOriginOnboarding": "Onboarding", + "automaticColorsOriginRoutine": "Routine", "availableModes": "Available Modes", "awaitingApprovalFromAgents_one": "Awaiting approval from {{count}} agent", "awaitingApprovalFromAgents_other": "Awaiting approval from {{count}} agents", @@ -209,7 +265,10 @@ "collapseToolbar": "Collapse toolbar", "color": "Color", "colorBlue": "Blue", + "colorCyan": "Cyan", "colorGreen": "Green", + "colorGray": "Gray", + "colorIndigo": "Indigo", "colorOrange": "Orange", "colorPink": "Pink", "colorPurple": "Purple", @@ -1439,6 +1498,7 @@ "sortCustom": "Custom", "sortCustomDescription": "The manual order you set for tasks.", "sortDirection": "Sort direction {{direction}}", + "sortSummary": "{{sort}}, {{direction}}", "sortLastActivity": "Last activity", "sortLastActivityDescription": "Last user or agent action. Viewing a task does not change it.", "sortStatus": "Status", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 5f33062137..9075d6c53c 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -127,6 +127,62 @@ "authenticationRequiredPleaseLogInVia": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď; ƥĺēàśē ĺōĝ ĩń vĩà ţĥē ţēŕḿĩńàĺ", "autoMarkReviewedOnScroll": "Àũţō-ḿàŕķ ŕēvĩēŵēď ōń śćŕōĺĺ", "autoStart": "Àũţō-śţàŕţ", + "automaticColorSource": "Àũţōḿàţĩć: {{rule}}. Màńũàĺ ćōĺōŕ ĩś ĥĩďďēń ŵĥĩĺē ţĥĩś ŕũĺē ḿàţćĥēś.", + "automaticColors": "Àũţōḿàţĩć ćōĺōŕś", + "automaticColorsAddRule": "Àďď ŕũĺē", + "automaticColorsBackToRule": "Bàćķ ţō ŕũĺē", + "automaticColorsCompleteRule": "Ćōḿƥĺēţē ţĥĩś ŕũĺē ƀēƒōŕē ēńàƀĺĩńĝ ĩţ.", + "automaticColorsCondition": "Ćōńďĩţĩōń", + "automaticColorsDimensionExecutorProfile": "Ēxēćũţōŕ ƥŕōƒĩĺē", + "automaticColorsDimensionOrigin": "Ōŕĩĝĩń", + "automaticColorsDimensionPriority": "Ƥŕĩōŕĩţŷ", + "automaticColorsDimensionRepository": "Ŕēƥōśĩţōŕŷ", + "automaticColorsDimensionTaskState": "Ţàśķ śţàţē", + "automaticColorsDimensionWorkflow": "Ŵōŕķƒĺōŵ", + "automaticColorsDimensionWorkflowStep": "Ŵōŕķƒĺōŵ śţēƥ", + "automaticColorsEnable": "Ēńàƀĺē ŕũĺē", + "automaticColorsEnabled": "Àũţōḿàţĩć ćōĺōŕś", + "automaticColorsFixedColor": "Fĩxēď ćōĺōŕ", + "automaticColorsHelp": "Ŕũĺēś ŕũń ŵĥēń ţàśķś àƥƥēàŕ àńď ŵĥēńēvēŕ ţĥēĩŕ ŵōŕķƒĺōŵ śţēƥ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, ēxēćũţōŕ ƥŕōƒĩĺē, śţàţē, ƥŕĩōŕĩţŷ, ōŕ ōŕĩĝĩń ćĥàńĝēś. Ćĥàńĝĩńĝ ŕũĺē ōŕďēŕ, ōũţƥũţ, ōŕ ēńàƀĺēď śţàţē àĺśō ũƥďàţēś ćōĺōŕś.", + "automaticColorsHelpLabel": "Ŵĥēń àũţōḿàţĩć ćōĺōŕś àƥƥĺŷ", + "automaticColorsIncomplete": "Ĩńćōḿƥĺēţē", + "automaticColorsMoveDown": "Mōvē ŕũĺē ďōŵń", + "automaticColorsMoveUp": "Mōvē ŕũĺē ũƥ", + "automaticColorsNoRepositories": "Nō ŕēƥōśĩţōŕĩēś ƒōũńď.", + "automaticColorsOutput": "Ōũţƥũţ", + "automaticColorsPersonalGlobal": "Ƥēŕśōńàĺ śēţţĩńĝ. Àƥƥĺĩēś àćŕōśś śĩďēƀàŕ vĩēŵś àńď ŵōŕķśƥàćēś.", + "automaticColorsPrecedence": "Ţĥē ƒĩŕśţ ḿàţćĥĩńĝ ŕũĺē ŵĩńś. Màńũàĺ ćōĺōŕś ŕēḿàĩń àś ƒàĺĺƀàćķ.", + "automaticColorsRefreshRepositories": "Ŕēƒŕēśĥ ŕēƥōśĩţōŕĩēś", + "automaticColorsRemoveRule": "Ŕēḿōvē ŕũĺē", + "automaticColorsRepositoryError": "Śōḿē ŕēƥōśĩţōŕŷ śōũŕćēś ćōũĺď ńōţ ƀē ĺōàďēď.", + "automaticColorsRepositoryLocal": "Đĩśćōvēŕēď ĺōćàĺ ŕēƥōśĩţōŕĩēś", + "automaticColorsRepositoryRemote": "Ŕēḿōţē ŕēƥōśĩţōŕĩēś", + "automaticColorsRepositoryUnavailable": "Ũńàvàĩĺàƀĺē ţàŕĝēţ", + "automaticColorsRepositoryWorkspace": "Ŵōŕķśƥàćē ŕēƥōśĩţōŕĩēś", + "automaticColorsReorderHandle": "Ŕēōŕďēŕ ŕũĺē {{number}}", + "automaticColorsReorderable": "śōŕţàƀĺē ŕũĺē", + "automaticColorsRule": "Ŕũĺē {{number}}", + "automaticColorsRuleCount_one": "{{count}} ŕũĺē", + "automaticColorsRuleCount_other": "{{count}} ŕũĺēś", + "automaticColorsRuleLimit": "Ýōũ ćàń àďď ũƥ ţō 50 ŕũĺēś.", + "automaticColorsSaveError": "Ćōũĺď ńōţ śàvē àũţōḿàţĩć ćōĺōŕś.", + "manualColorSaveError": "Ćōũĺď ńōţ śàvē ţĥē ţàśķ ćōĺōŕ.", + "automaticColorsSearchRepositories": "Śēàŕćĥ ŕēƥōśĩţōŕĩēś", + "automaticColorsSelectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ", + "automaticColorsSelectTarget": "Śēĺēćţ à ţàŕĝēţ", + "automaticColorsSummaryOff": "Ōƒƒ", + "automaticColorsSummaryRules_one": "{{count}} ēńàƀĺēď ŕũĺē", + "automaticColorsSummaryRules_other": "{{count}} ēńàƀĺēď ŕũĺēś", + "automaticColorsTiming": "Ŕũĺēś àƥƥĺŷ ţō ēxĩśţĩńĝ àńď ńēŵ śĩďēƀàŕ ţàśķś. Ćōĺōŕś ũƥďàţē ŵĥēń ţàśķ ďēţàĩĺś ōŕ ŕũĺēś ćĥàńĝē.", + "automaticColorsUnavailable": "Ũńàvàĩĺàƀĺē", + "automaticColorsUseStepColor": "Ũśē śţēƥ ćōĺōŕ", + "automaticColorsOriginAgentCreated": "Àĝēńţ-ćŕēàţēď", + "automaticColorsOriginAutomationRun": "Àũţōḿàţĩōń ŕũń", + "automaticColorsOriginAutomationTask": "Àũţōḿàţĩōń ţàśķ", + "automaticColorsOriginKanban": "Kàńƀàń", + "automaticColorsOriginManual": "Màńũàĺ", + "automaticColorsOriginOnboarding": "Ōńƀōàŕďĩńĝ", + "automaticColorsOriginRoutine": "Ŕōũţĩńē", "availableModes": "Àvàĩĺàƀĺē Ḿōďēś", "awaitingApprovalFromAgents_one": "Àŵàĩţĩńĝ àƥƥŕōvàĺ ƒŕōḿ {{count}} àĝēńţ", "awaitingApprovalFromAgents_other": "Àŵàĩţĩńĝ àƥƥŕōvàĺ ƒŕōḿ {{count}} àĝēńţś", @@ -209,7 +265,10 @@ "collapseToolbar": "Ćōĺĺàƥśē ţōōĺƀàŕ", "color": "Ćōĺōŕ", "colorBlue": "Ɓĺũē", + "colorCyan": "Ćŷàń", "colorGreen": "Ĝŕēēń", + "colorGray": "Ĝŕàŷ", + "colorIndigo": "Ĩńďĩĝō", "colorOrange": "Ōŕàńĝē", "colorPink": "Ƥĩńķ", "colorPurple": "Ƥũŕƥĺē", @@ -1439,6 +1498,7 @@ "sortCustom": "Ćũśţōḿ", "sortCustomDescription": "Ţĥē ḿàńũàĺ ōŕďēŕ ŷōũ śēţ ƒōŕ ţàśķś.", "sortDirection": "Śōŕţ ďĩŕēćţĩōń {{direction}}", + "sortSummary": "{{sort}},{{direction}}", "sortLastActivity": "Ĺàśţ àćţĩvĩţŷ", "sortLastActivityDescription": "Ĺàśţ ũśēŕ ōŕ àĝēńţ àćţĩōń. Vĩēŵĩńĝ à ţàśķ ďōēś ńōţ ćĥàńĝē ĩţ.", "sortStatus": "Śţàţũś", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 6280578fcc..66aa9d7949 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -126,6 +126,62 @@ "authenticationRequiredPleaseLogInVia": "É necessária autenticação; inicie sessão através do terminal", "autoMarkReviewedOnScroll": "Marcar como revisto automaticamente ao deslocar", "autoStart": "Arranque automático", + "automaticColorSource": "Automática: {{rule}}. A cor manual fica oculta enquanto esta regra corresponde.", + "automaticColors": "Cores automáticas", + "automaticColorsAddRule": "Adicionar regra", + "automaticColorsBackToRule": "Voltar à regra", + "automaticColorsCompleteRule": "Conclua esta regra antes de a ativar.", + "automaticColorsCondition": "Condição", + "automaticColorsDimensionExecutorProfile": "Perfil do executor", + "automaticColorsDimensionOrigin": "Origem", + "automaticColorsDimensionPriority": "Prioridade", + "automaticColorsDimensionRepository": "Repositório", + "automaticColorsDimensionTaskState": "Estado da tarefa", + "automaticColorsDimensionWorkflow": "Fluxo de trabalho", + "automaticColorsDimensionWorkflowStep": "Etapa do fluxo de trabalho", + "automaticColorsEnable": "Ativar regra", + "automaticColorsEnabled": "Cores automáticas", + "automaticColorsFixedColor": "Cor fixa", + "automaticColorsHelp": "As regras são avaliadas quando as tarefas aparecem e sempre que a respetiva etapa do fluxo de trabalho, repositório, fluxo de trabalho, perfil do executor, estado, prioridade ou origem muda. Alterar a ordem, o resultado ou o estado ativo das regras também atualiza as cores.", + "automaticColorsHelpLabel": "Quando se aplicam as cores automáticas", + "automaticColorsIncomplete": "Incompleta", + "automaticColorsMoveDown": "Mover regra para baixo", + "automaticColorsMoveUp": "Mover regra para cima", + "automaticColorsNoRepositories": "Não foram encontrados repositórios.", + "automaticColorsOutput": "Resultado", + "automaticColorsPersonalGlobal": "Definição pessoal. Aplica-se a todas as vistas da barra lateral e áreas de trabalho.", + "automaticColorsPrecedence": "A primeira regra correspondente ganha. As cores manuais continuam a ser a alternativa.", + "automaticColorsRefreshRepositories": "Atualizar repositórios", + "automaticColorsRemoveRule": "Remover regra", + "automaticColorsRepositoryError": "Não foi possível carregar algumas fontes de repositórios.", + "automaticColorsRepositoryLocal": "Repositórios locais descobertos", + "automaticColorsRepositoryRemote": "Repositórios remotos", + "automaticColorsRepositoryUnavailable": "Destino indisponível", + "automaticColorsRepositoryWorkspace": "Repositórios da área de trabalho", + "automaticColorsReorderHandle": "Reordenar regra {{number}}", + "automaticColorsReorderable": "regra ordenável", + "automaticColorsRule": "Regra {{number}}", + "automaticColorsRuleCount_one": "{{count}} regra", + "automaticColorsRuleCount_other": "{{count}} regras", + "automaticColorsRuleLimit": "Pode adicionar até 50 regras.", + "automaticColorsSaveError": "Não foi possível guardar as cores automáticas.", + "manualColorSaveError": "Não foi possível guardar a cor da tarefa.", + "automaticColorsSearchRepositories": "Pesquisar repositórios", + "automaticColorsSelectRepository": "Selecionar repositório", + "automaticColorsSelectTarget": "Selecionar um destino", + "automaticColorsSummaryOff": "Desativadas", + "automaticColorsSummaryRules_one": "{{count}} regra ativa", + "automaticColorsSummaryRules_other": "{{count}} regras ativas", + "automaticColorsTiming": "As regras aplicam-se a tarefas existentes e novas na barra lateral. As cores são atualizadas quando os detalhes da tarefa ou as regras mudam.", + "automaticColorsUnavailable": "Indisponível", + "automaticColorsUseStepColor": "Usar cor da etapa", + "automaticColorsOriginAgentCreated": "Criada pelo agente", + "automaticColorsOriginAutomationRun": "Execução de automação", + "automaticColorsOriginAutomationTask": "Tarefa de automação", + "automaticColorsOriginKanban": "Quadro", + "automaticColorsOriginManual": "Definida manualmente", + "automaticColorsOriginOnboarding": "Configuração inicial", + "automaticColorsOriginRoutine": "Rotina", "availableModes": "Modos disponíveis", "awaitingApprovalFromAgents_one": "A aguardar a aprovação de {{count}} agente", "awaitingApprovalFromAgents_other": "A aguardar a aprovação de {{count}} agentes", @@ -208,7 +264,10 @@ "collapseToolbar": "Recolher a barra de ferramentas", "color": "Cor", "colorBlue": "Azul", + "colorCyan": "Ciano", "colorGreen": "Verde", + "colorGray": "Cinzento", + "colorIndigo": "Índigo", "colorOrange": "Laranja", "colorPink": "Rosa", "colorPurple": "Roxo", @@ -1383,6 +1442,7 @@ "sortCustom": "Personalizada", "sortCustomDescription": "A ordem manual que definiu para as tarefas.", "sortDirection": "Sentido da ordenação {{direction}}", + "sortSummary": "{{sort}}: {{direction}}", "sortLastActivity": "Última atividade", "sortLastActivityDescription": "Última ação do utilizador ou turno do agente. Ver uma tarefa não conta como atividade.", "sortStatus": "Estado", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 3fe83defb0..2856d17181 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -126,6 +126,62 @@ "authenticationRequiredPleaseLogInVia": "需要身份验证;请通过终端登录", "autoMarkReviewedOnScroll": "滚动时自动标记为已审阅", "autoStart": "自动启动", + "automaticColorSource": "自动颜色:{{rule}}。此规则匹配时会隐藏手动颜色。", + "automaticColors": "自动颜色", + "automaticColorsAddRule": "添加规则", + "automaticColorsBackToRule": "返回规则", + "automaticColorsCompleteRule": "请先完成此规则,再启用它。", + "automaticColorsCondition": "条件", + "automaticColorsDimensionExecutorProfile": "执行器配置", + "automaticColorsDimensionOrigin": "来源", + "automaticColorsDimensionPriority": "优先级", + "automaticColorsDimensionRepository": "代码仓库", + "automaticColorsDimensionTaskState": "任务状态", + "automaticColorsDimensionWorkflow": "工作流", + "automaticColorsDimensionWorkflowStep": "工作流步骤", + "automaticColorsEnable": "启用规则", + "automaticColorsEnabled": "自动颜色", + "automaticColorsFixedColor": "固定颜色", + "automaticColorsHelp": "任务出现时,以及任务的工作流步骤、代码仓库、工作流、执行器配置、状态、优先级或来源发生变化时,都会评估规则。更改规则顺序、输出或启用状态也会更新颜色。", + "automaticColorsHelpLabel": "自动颜色的应用时机", + "automaticColorsIncomplete": "未完成", + "automaticColorsMoveDown": "下移规则", + "automaticColorsMoveUp": "上移规则", + "automaticColorsNoRepositories": "未找到代码仓库。", + "automaticColorsOutput": "输出", + "automaticColorsPersonalGlobal": "个人设置。应用于所有侧边栏视图和工作区。", + "automaticColorsPrecedence": "第一个匹配的规则生效。没有匹配时使用手动颜色。", + "automaticColorsRefreshRepositories": "刷新代码仓库", + "automaticColorsRemoveRule": "移除规则", + "automaticColorsRepositoryError": "部分代码仓库来源无法加载。", + "automaticColorsRepositoryLocal": "发现的本地代码仓库", + "automaticColorsRepositoryRemote": "远程代码仓库", + "automaticColorsRepositoryUnavailable": "目标不可用", + "automaticColorsRepositoryWorkspace": "工作区代码仓库", + "automaticColorsReorderHandle": "重新排列规则 {{number}}", + "automaticColorsReorderable": "可排序规则", + "automaticColorsRule": "规则 {{number}}", + "automaticColorsRuleCount_one": "{{count}} 条规则", + "automaticColorsRuleCount_other": "{{count}} 条规则", + "automaticColorsRuleLimit": "最多可以添加 50 条规则。", + "automaticColorsSaveError": "无法保存自动颜色。", + "manualColorSaveError": "无法保存任务颜色。", + "automaticColorsSearchRepositories": "搜索代码仓库", + "automaticColorsSelectRepository": "选择代码仓库", + "automaticColorsSelectTarget": "选择目标", + "automaticColorsSummaryOff": "关闭", + "automaticColorsSummaryRules_one": "{{count}} 条启用规则", + "automaticColorsSummaryRules_other": "{{count}} 条启用规则", + "automaticColorsTiming": "规则适用于现有和新建的侧边栏任务。任务详情或规则更改时,颜色会更新。", + "automaticColorsUnavailable": "不可用", + "automaticColorsUseStepColor": "使用步骤颜色", + "automaticColorsOriginAgentCreated": "代理创建", + "automaticColorsOriginAutomationRun": "自动化运行", + "automaticColorsOriginAutomationTask": "自动化任务", + "automaticColorsOriginKanban": "看板", + "automaticColorsOriginManual": "手动", + "automaticColorsOriginOnboarding": "引导", + "automaticColorsOriginRoutine": "例行任务", "availableModes": "可用模式", "awaitingApprovalFromAgents_one": "等待 {{count}} 个智能体批准", "awaitingApprovalFromAgents_other": "等待 {{count}} 个智能体批准", @@ -208,7 +264,10 @@ "collapseToolbar": "折叠工具栏", "color": "颜色", "colorBlue": "蓝色", + "colorCyan": "青色", "colorGreen": "绿色", + "colorGray": "灰色", + "colorIndigo": "靛蓝色", "colorOrange": "橙色", "colorPink": "粉色", "colorPurple": "紫色", @@ -1382,6 +1441,7 @@ "sortCustom": "自定义", "sortCustomDescription": "你为任务设置的手动顺序。", "sortDirection": "排序方向 {{direction}}", + "sortSummary": "{{sort}},{{direction}}", "sortLastActivity": "最后活动", "sortLastActivityDescription": "最近一次用户操作或智能体回合。查看任务不会改变此时间。", "sortStatus": "状态", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 93c9fb9189..cf1a5e61eb 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -126,6 +126,62 @@ "authenticationRequiredPleaseLogInVia": "需要身份驗證;請通過終端登入", "autoMarkReviewedOnScroll": "滾動時自動標記為已審閱", "autoStart": "自動啓動", + "automaticColorSource": "自動顏色:{{rule}}。此規則匹配時會隱藏手動顏色。", + "automaticColors": "自動顏色", + "automaticColorsAddRule": "新增規則", + "automaticColorsBackToRule": "返回規則", + "automaticColorsCompleteRule": "請先完成此規則,再啓用它。", + "automaticColorsCondition": "條件", + "automaticColorsDimensionExecutorProfile": "執行器設定檔", + "automaticColorsDimensionOrigin": "來源", + "automaticColorsDimensionPriority": "優先級", + "automaticColorsDimensionRepository": "程式碼儲存庫", + "automaticColorsDimensionTaskState": "任務狀態", + "automaticColorsDimensionWorkflow": "工作流程", + "automaticColorsDimensionWorkflowStep": "工作流程步驟", + "automaticColorsEnable": "啓用規則", + "automaticColorsEnabled": "自動顏色", + "automaticColorsFixedColor": "固定顏色", + "automaticColorsHelp": "任務出現時,以及任務的工作流程步驟、程式碼儲存庫、工作流程、執行器設定檔、狀態、優先級或來源發生變化時,都會評估規則。更改規則順序、輸出或啓用狀態也會更新顏色。", + "automaticColorsHelpLabel": "自動顏色的應用時機", + "automaticColorsIncomplete": "未完成", + "automaticColorsMoveDown": "下移規則", + "automaticColorsMoveUp": "上移規則", + "automaticColorsNoRepositories": "未找到程式碼儲存庫。", + "automaticColorsOutput": "輸出", + "automaticColorsPersonalGlobal": "個人設定。應用於所有側邊欄視圖和工作區。", + "automaticColorsPrecedence": "第一個匹配的規則生效。沒有匹配時使用手動顏色。", + "automaticColorsRefreshRepositories": "刷新程式碼儲存庫", + "automaticColorsRemoveRule": "移除規則", + "automaticColorsRepositoryError": "部分程式碼儲存庫來源無法載入。", + "automaticColorsRepositoryLocal": "發現的本地程式碼儲存庫", + "automaticColorsRepositoryRemote": "遠端程式碼儲存庫", + "automaticColorsRepositoryUnavailable": "目標不可用", + "automaticColorsRepositoryWorkspace": "工作區程式碼儲存庫", + "automaticColorsReorderHandle": "重新排列規則 {{number}}", + "automaticColorsReorderable": "可排序規則", + "automaticColorsRule": "規則 {{number}}", + "automaticColorsRuleCount_one": "{{count}} 條規則", + "automaticColorsRuleCount_other": "{{count}} 條規則", + "automaticColorsRuleLimit": "最多可以新增 50 條規則。", + "automaticColorsSaveError": "無法儲存自動顏色。", + "manualColorSaveError": "無法儲存任務顏色。", + "automaticColorsSearchRepositories": "搜尋程式碼儲存庫", + "automaticColorsSelectRepository": "選擇程式碼儲存庫", + "automaticColorsSelectTarget": "選擇目標", + "automaticColorsSummaryOff": "關閉", + "automaticColorsSummaryRules_one": "{{count}} 條啓用規則", + "automaticColorsSummaryRules_other": "{{count}} 條啓用規則", + "automaticColorsTiming": "規則適用於現有和新建的側邊欄任務。任務詳情或規則更改時,顏色會更新。", + "automaticColorsUnavailable": "不可用", + "automaticColorsUseStepColor": "使用步驟顏色", + "automaticColorsOriginAgentCreated": "代理建立", + "automaticColorsOriginAutomationRun": "自動化執行", + "automaticColorsOriginAutomationTask": "自動化任務", + "automaticColorsOriginKanban": "看板", + "automaticColorsOriginManual": "手動", + "automaticColorsOriginOnboarding": "引導", + "automaticColorsOriginRoutine": "例行任務", "availableModes": "可用模式", "awaitingApprovalFromAgents_one": "等待 {{count}} 個代理程式批准", "awaitingApprovalFromAgents_other": "等待 {{count}} 個代理程式批准", @@ -208,7 +264,10 @@ "collapseToolbar": "摺疊工具欄", "color": "顏色", "colorBlue": "藍色", + "colorCyan": "青色", "colorGreen": "綠色", + "colorGray": "灰色", + "colorIndigo": "靛藍色", "colorOrange": "橙色", "colorPink": "粉色", "colorPurple": "紫色", @@ -1382,6 +1441,7 @@ "sortCustom": "自訂", "sortCustomDescription": "你為任務設定的手動順序。", "sortDirection": "排序方向 {{direction}}", + "sortSummary": "{{sort}},{{direction}}", "sortLastActivity": "最後活動", "sortLastActivityDescription": "最近一次用戶操作或代理程式回合。查看任務不會改變此時間。", "sortStatus": "狀態", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index a6790e36af..da2d09b839 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -126,6 +126,62 @@ "authenticationRequiredPleaseLogInVia": "需要身分驗證;請通過終端登入", "autoMarkReviewedOnScroll": "滾動時自動標記為已審閱", "autoStart": "自動啟動", + "automaticColorSource": "自動顏色:{{rule}}。此規則匹配時會隱藏手動顏色。", + "automaticColors": "自動顏色", + "automaticColorsAddRule": "新增規則", + "automaticColorsBackToRule": "返回規則", + "automaticColorsCompleteRule": "請先完成此規則,再啟用它。", + "automaticColorsCondition": "條件", + "automaticColorsDimensionExecutorProfile": "執行器設定檔", + "automaticColorsDimensionOrigin": "來源", + "automaticColorsDimensionPriority": "優先順序", + "automaticColorsDimensionRepository": "程式碼儲存庫", + "automaticColorsDimensionTaskState": "任務狀態", + "automaticColorsDimensionWorkflow": "工作流程", + "automaticColorsDimensionWorkflowStep": "工作流程步驟", + "automaticColorsEnable": "啟用規則", + "automaticColorsEnabled": "自動顏色", + "automaticColorsFixedColor": "固定顏色", + "automaticColorsHelp": "任務出現時,以及任務的工作流程步驟、程式碼儲存庫、工作流程、執行器設定檔、狀態、優先順序或來源發生變化時,都會評估規則。更改規則順序、輸出或啟用狀態也會更新顏色。", + "automaticColorsHelpLabel": "自動顏色的應用時機", + "automaticColorsIncomplete": "未完成", + "automaticColorsMoveDown": "下移規則", + "automaticColorsMoveUp": "上移規則", + "automaticColorsNoRepositories": "未找到程式碼儲存庫。", + "automaticColorsOutput": "輸出", + "automaticColorsPersonalGlobal": "個人設定。應用於所有側邊欄檢視和工作區。", + "automaticColorsPrecedence": "第一個匹配的規則生效。沒有匹配時使用手動顏色。", + "automaticColorsRefreshRepositories": "重新整理程式碼儲存庫", + "automaticColorsRemoveRule": "移除規則", + "automaticColorsRepositoryError": "部分程式碼儲存庫來源無法載入。", + "automaticColorsRepositoryLocal": "發現的原生程式碼儲存庫", + "automaticColorsRepositoryRemote": "遠端程式碼儲存庫", + "automaticColorsRepositoryUnavailable": "目標不可用", + "automaticColorsRepositoryWorkspace": "工作區程式碼儲存庫", + "automaticColorsReorderHandle": "重新排列規則 {{number}}", + "automaticColorsReorderable": "可排序規則", + "automaticColorsRule": "規則 {{number}}", + "automaticColorsRuleCount_one": "{{count}} 條規則", + "automaticColorsRuleCount_other": "{{count}} 條規則", + "automaticColorsRuleLimit": "最多可以新增 50 條規則。", + "automaticColorsSaveError": "無法儲存自動顏色。", + "manualColorSaveError": "無法儲存任務顏色。", + "automaticColorsSearchRepositories": "搜尋程式碼儲存庫", + "automaticColorsSelectRepository": "選擇程式碼儲存庫", + "automaticColorsSelectTarget": "選擇目標", + "automaticColorsSummaryOff": "關閉", + "automaticColorsSummaryRules_one": "{{count}} 條啟用規則", + "automaticColorsSummaryRules_other": "{{count}} 條啟用規則", + "automaticColorsTiming": "規則適用於現有和新建的側邊欄任務。任務詳情或規則更改時,顏色會更新。", + "automaticColorsUnavailable": "不可用", + "automaticColorsUseStepColor": "使用步驟顏色", + "automaticColorsOriginAgentCreated": "代理建立", + "automaticColorsOriginAutomationRun": "自動化執行", + "automaticColorsOriginAutomationTask": "自動化任務", + "automaticColorsOriginKanban": "看板", + "automaticColorsOriginManual": "手動", + "automaticColorsOriginOnboarding": "引導", + "automaticColorsOriginRoutine": "例行任務", "availableModes": "可用模式", "awaitingApprovalFromAgents_one": "等待 {{count}} 個代理程式批准", "awaitingApprovalFromAgents_other": "等待 {{count}} 個代理程式批准", @@ -208,7 +264,10 @@ "collapseToolbar": "摺疊工具欄", "color": "顏色", "colorBlue": "藍色", + "colorCyan": "青色", "colorGreen": "綠色", + "colorGray": "灰色", + "colorIndigo": "靛藍色", "colorOrange": "橙色", "colorPink": "粉色", "colorPurple": "紫色", @@ -1382,6 +1441,7 @@ "sortCustom": "自訂", "sortCustomDescription": "你為任務設定的手動順序。", "sortDirection": "排序方向 {{direction}}", + "sortSummary": "{{sort}},{{direction}}", "sortLastActivity": "最後活動", "sortLastActivityDescription": "最近一次使用者操作或代理程式回合。檢視任務不會改變此時間。", "sortStatus": "狀態", diff --git a/docs/decisions/0041-backend-owned-portable-user-settings.md b/docs/decisions/0041-backend-owned-portable-user-settings.md index e0b002f01c..b8c8b5f791 100644 --- a/docs/decisions/0041-backend-owned-portable-user-settings.md +++ b/docs/decisions/0041-backend-owned-portable-user-settings.md @@ -1,6 +1,6 @@ # 0041: Backend-Owned Portable User Settings -**Status:** accepted (amended 2026-07-31) +**Status:** accepted (amended 2026-09-03) **Date:** 2026-07-15 **Area:** frontend, backend @@ -25,18 +25,28 @@ transient drafts. User-settings PATCH semantics remain unchanged: omitted fields are unchanged, explicit `null` clears nullable fields where supported, and an empty array is an explicit empty value. +Manual sidebar task colors are portable personal settings. They are not +device-local state and they are not shared task fields. The backend stores a +per-user color decision for each task. A clear decision remains as a tombstone +while legacy browser import support exists. + +The frontend can read legacy browser colors only as migration input. The +backend imports a legacy color only when no stored color decision exists for +that task. The frontend removes the legacy value after a successful import. It +does not use legacy data as an authoritative display fallback. + The backend also owns the effective value of an omitted portable setting. It starts from one default settings value and overlays fields present in the stored JSON before emitting a complete HTTP or WebSocket payload. The frontend uses one wire-to-store mapper for boot hydration, mutation responses, and live -updates; an unloaded frontend state is only a rendering placeholder, not an -independent source of product defaults. +updates. An unloaded frontend state is only a rendering placeholder. It is not +an independent source of product defaults. ## Consequences - Stale browser values cannot override backend settings during hydration. -- Failed optimistic writes are not reconstructed from browser retry markers; - the backend value wins on the next load. +- Failed optimistic writes are not reconstructed from browser retry markers. + The backend value wins on the next load. - Cross-device behavior is consistent because portable settings have one durable owner. - Changing an omitted-value default no longer requires editing several @@ -44,6 +54,12 @@ independent source of product defaults. - Frontend compatibility handling remains centralized in the common mapper. - Device-local layout, collapse, pane-size, and transient draft storage remain unaffected. +- Manual task colors follow a user across browsers without changing another + user's task presentation. +- Per-task patches and clear tombstones prevent stale browsers from replacing + other colors or restoring a cleared color during migration. +- The legacy import path is temporary. Tombstones can be removed only after + that path is retired. ## Alternatives Considered @@ -53,5 +69,11 @@ independent source of product defaults. 2. **Keep retry markers without fallback reads.** Rejected because replay still requires treating browser state as a second durable source. 3. **Let every frontend consumer apply wire fallbacks.** Rejected because HTTP, - WebSocket, and save-response paths can drift and produce different effective - settings for the same backend value. + WebSocket, and save-response paths can drift. These paths can produce + different effective settings for the same backend value. +4. **Keep manual task colors device-local.** Rejected because users expect a + personal color to remain consistent in each browser. +5. **Store one shared color on each task.** Rejected because manual sidebar + colors are personal presentation and must not change another user's view. +6. **Import browser values as full replacements.** Rejected because one stale + browser can erase newer server values or colors from another browser. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 6203fe7d3b..30d5ce6817 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -46,7 +46,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0038 | [Quick Chat Repository Isolation](0038-quick-chat-repository-isolation.md) | accepted | backend, frontend | 2026-07-14 | | 0039 | [Native desktop integration boundary](0039-native-desktop-integration-boundary.md) | accepted (amended 2026-07-24) | desktop, frontend, backend, infra | 2026-07-15 | | 0040 | [Separate updater integrity from OS publisher identity](0040-separate-updater-integrity-from-os-publisher-identity.md) | accepted | desktop, infra, workflow | 2026-07-15 | -| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted (amended 2026-07-31) | backend, frontend | 2026-07-15 | +| 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted (amended 2026-09-03) | backend, frontend | 2026-07-15 | | 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 | | 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 | | 2026-07-14-typed-utility-chat-sessions | [Typed Utility Chats Share the Quick Chat Session Model](2026-07-14-typed-utility-chat-sessions.md) | accepted | backend, frontend | 2026-07-14 | diff --git a/docs/plans/sidebar-automatic-task-colors/plan.md b/docs/plans/sidebar-automatic-task-colors/plan.md new file mode 100644 index 0000000000..8edcfcbdfc --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/plan.md @@ -0,0 +1,164 @@ +--- +created: 2026-08-30 +status: done +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +legacy_specs: [] +--- + +# Implementation Plan: Sidebar Task Colors + +## Overview + +Add the portable rule contract first. Then add rule resolution and repository identity before repository discovery and the responsive editor. + +This order keeps persistence, derivation, and user interaction independently testable. The final work order proves the complete desktop and mobile flow. + +Tasks 05 and 06 change the original manual-color boundary. They add atomic backend storage first, then migrate browser values and switch each client. + +## Scope + +### In scope + +- Compact Sort and Group by disclosures. +- Personal automatic colors shared by all saved sidebar views and workspaces. +- Ordered first-match rules for seven task facts. +- Safe workflow-step color reuse. +- Shared dimension options with explicit differences from sidebar filters. +- Search and refresh for workspace, local, remote, and plugin repositories. +- Desktop and mobile rule editing. +- Portable settings, failure recovery, localization, and E2E coverage. +- Personal manual colors that synchronize through backend settings. +- A safe import of legacy browser colors. + +### Out of scope + +- Shared workspace colors or a task-owned color field. +- Additional rule dimensions. +- Rule import or export. +- Real-time cross-browser delivery of manual color changes. +- Public documentation. Existing public docs do not describe sidebar view settings. + +## Technical approach + +### Portable settings contract + +Add `sidebar_task_color_automation` to the typed Go user settings model. Carry it through DTOs, service validation, stored JSON, and events. + +Store rule order, targets, stored labels, and output selections in the `users.settings` JSON value. Preserve disabled incomplete rules and enforce exact field limits. + +Add the matching wire and store types in the web application. Normalize missing or invalid values through the common user-settings mapper. + +Add one serialized optimistic mutation helper. It sends a complete replacement and respects settings revisions. + +Store manual colors as per-user task decisions in `users.settings`. Keep colors and clear tombstones separate from automatic rules. + +Add a narrow per-task patch to `PATCH /api/v1/user/settings`. Apply each patch to the latest settings value during every CAS attempt. + +Use `if_missing` patches for legacy import. Normal edits overwrite supplied task IDs. Clear operations store tombstones. + +Read `kandev.taskColors` only after backend settings load. Import valid values in bounded batches, then remove the legacy browser value. + +Replace the localStorage subscription with a selector for backend settings. Keep automatic colors as presentation precedence over the stored manual value. + +### Dimension options and color presentation + +Extract shared workflow and step option builders from `use-filter-value-options.ts`. Keep filter operators and filter matching in their current registry and engine. + +Use explicit automatic-rule semantics for executor profile, raw task state, repository identity, priority, and normalized origin. Label the executor selector Executor profile. + +Add a ten-color automatic palette without changing the seven-color manual menu. Add a safe workflow-step parser with a gray fallback for unsupported values. + +### Rule resolution + +Add a pure rule normalizer and resolver under `apps/web/lib/sidebar/`. Keep persisted color keys separate from CSS classes. + +Extend `TaskSwitcherItem` with workspace, priority, origin, executor profile, step color, and repository identities. Update desktop, mobile, and archived projections. + +Add the pure repository identity matcher with the rule resolver. Join task repository links to full repository records at each projection boundary. + +Update the task color menu to identify the winning automatic rule. Do not remove the manual choice. + +### Repository catalog + +Compose saved repositories, local discovery, built-in remote providers, and plugin providers behind one catalog hook. + +Add an explicit refresh generation to remote repository discovery. Reject late results and retain successful provider groups after partial errors. + +Build workspace, provider, and local catalog options with the identity primitives from Task 02. Match provider options with provider, host, scope, and repository ID. + +### Responsive editor + +Extract the Task row header into a shared disclosure primitive. Use it for Sort, Group by, Task row, and Automatic colors. + +Add ordered rule cards and a focused repository picker. Keep the mobile picker inside the existing drawer. + +Create new rules as disabled incomplete rules. If the list contains 50 rules, disable Add rule and show a localized message. + +Add localized copy in all shipped catalogs. Generate the Traditional Chinese catalogs with the existing script. + +Keep the shared disclosure padding compact and consistent. Override the desktop popover primitive's default flex gap so adjacent sections remain contiguous. Bound the desktop popover by the available viewport with a `100dvh` fallback, retain the drawer as the mobile scroll owner, and expose scope and timing guidance through one hover, focus, and tap-accessible info icon. Rule headings include the localized condition dimension, and the desktop condition and target fields share aligned label and control rows. + +## Tests + +| Acceptance criteria | Evidence | +| --- | --- | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.*` | Disclosure component tests and sidebar Playwright geometry | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.*` | Dimension-option tests, rule resolver tests, projection tests, task-row tests, and live recoloring Playwright flows | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.*` | Repository catalog tests and repository picker Playwright flows | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.1` through `.4` | Backend user-settings tests and frontend mapper or mutation tests | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.5` through `.11` | Desktop and mobile Playwright flows | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.1` through `.5` | Backend patch, import, tombstone, and CAS tests | +| `AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.6` through `.10` | Frontend migration, rollback, desktop, and mobile tests | + +## E2E tests + +- `apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts` covers ordered state rules, live recoloring, reload persistence, and the stored API value. +- `apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts` covers the drawer flow, touch repository selection, the focused picker pane, and reload persistence. +- `apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts` covers desktop manual-menu selection, backend persistence, reload, and legacy-key absence. +- `apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts` covers a server-backed manual color in the existing phone drawer. + +## Work orders + +- [x] [Task 01: Persist automatic color settings](task-01-persist-automatic-color-settings.md) +- [x] [Task 02: Resolve effective task colors](task-02-resolve-effective-task-colors.md) +- [x] [Task 03: Build repository target catalog](task-03-build-repository-target-catalog.md) +- [x] [Task 04: Deliver responsive automatic-color editor](task-04-deliver-responsive-automatic-color-editor.md) +- [x] [Task 05: Persist personal manual colors](task-05-persist-personal-manual-colors.md) +- [x] [Task 06: Adopt server-backed manual colors](task-06-adopt-server-backed-manual-colors.md) + +## Verification results + +- `go test ./internal/user/... ./internal/task/dto ./internal/task/handlers ./internal/task/service ./internal/backendapp`: 3,282 tests passed. +- `make -C apps/backend lint` and `make -C apps/backend build`: passed. +- Focused frontend Vitest suite: 12 files and 80 tests passed. +- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:zh-hant`, `pnpm run i18n:check`, and `pnpm run e2e:sleep-ratchet`: passed. +- `python3 scripts/lint-spec-files.py --all` and `git diff --check`: passed. +- Desktop and mobile automatic-color Playwright specs: one test passed in each project. +- Compact-layout follow-up: focused component tests and desktop or mobile Playwright coverage passed for the shared spacing, viewport scrolling, and responsive help icon. +- Rule-card polish follow-up: focused component tests and desktop or mobile Playwright coverage passed for the zero-gap editor, localized condition headings, and aligned repository targets. +- Task 05 backend and frontend settings verification: 1,158 Go tests across 7 packages and 88 focused Vitest tests passed. +- Task 06 focused frontend verification: 76 Vitest tests passed; typecheck, lint, Traditional Chinese generation, and i18n checks passed. +- Task 06 desktop and mobile sync Playwright specs: one test passed in each project. + +## Risks + +- User-settings writes can race with other open clients. Revision-aware mapping and serialized writes must prevent stale rollback. +- Plugin repository providers can return late or malformed data. Generation guards and identity validation must isolate those results. +- Workflow-step colors use more keys than the manual task palette. One safe registry must cover both sets. +- Global rules can contain targets from another workspace. Stored workspace identity and unavailable labels must prevent collisions. +- Desktop, mobile, and archived task projections expose different facts today. Each projection needs focused coverage. +- A global setting inside a saved-view editor can appear view-specific. Visible scope copy must remove that ambiguity. +- Whole-map replacement can erase a concurrent browser's edit. Manual colors need per-task patches that reapply after CAS conflicts. +- A later browser can contain an old local color. Clear tombstones must win against every import-missing request. +- Legacy browser data can exceed one request. The frontend must use bounded batches and remove the key only after all batches succeed. + +## Prerequisite + +If `apps/node_modules` is absent, run `pnpm install --frozen-lockfile` from `apps/` before the first package command. diff --git a/docs/plans/sidebar-automatic-task-colors/task-01-persist-automatic-color-settings.md b/docs/plans/sidebar-automatic-task-colors/task-01-persist-automatic-color-settings.md new file mode 100644 index 0000000000..288a6946b9 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-01-persist-automatic-color-settings.md @@ -0,0 +1,94 @@ +--- +id: "01-persist-automatic-color-settings" +title: "Persist automatic color settings" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.11 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.13 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.4 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 01: Persist Automatic Color Settings + +## Summary + +Add a typed, bounded automatic-color object to portable user settings. Store the object in the backend database and add one revision-aware frontend mutation path. + +## In scope + +- Go models, DTOs, controller mapping, service patch validation, defaults, and stored JSON. +- Frontend API types, settings state, normalization, boot mapping, and WebSocket mapping. +- A serialized optimistic mutation helper with safe rollback and localized error state. +- Exact limits for rule count, IDs, labels, identity fields, and local paths. +- Disabled incomplete rules and discriminated fixed or workflow-step outputs. + +## Out of scope + +- Rule matching. +- Repository discovery. +- Editor controls. + +## Acceptance + +- Missing settings produce disabled automation with an empty rule list. +- The `users.settings` JSON value preserves rule order, targets, stored labels, and output selections. +- The backend preserves disabled incomplete rules. It rejects enabled incomplete rules, malformed values, oversized fields, and more than 50 rules. +- Successful and failed writes preserve revision order and unrelated settings. + +## Verification + +```bash +(cd apps/backend && go test ./internal/user/...) +(cd apps/web && pnpm exec vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts lib/state/default-state.test.ts lib/task-color-automation-settings.test.ts) +``` + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/user/**/*_test.go` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/settings/user-settings-revision.ts` +- `apps/web/lib/ws/handlers/users.ts` +- `apps/web/lib/task-color-automation-settings.ts` + +## Dependencies + +None. + +## Risks + +- A shallow settings copy can alias rule slices. Apply helpers must replace slices. +- A failed older write must not roll back a newer confirmed write. + +## Parallelism + +`sequential` + +## Inputs + +- Requirement sections for ordered rules and portable settings. +- System-design sections for the rule model and settings persistence. +- Existing sidebar-view write journal and common user-settings mapper. + +## Results + +Implemented the bounded Go user-settings model, DTO/controller/service/store/event plumbing, defaults, corrupt-value isolation, and revision-safe complete replacement. Added the web wire/state types, SSR and WebSocket hydration, serialized optimistic mutation hook, and regression coverage for persistence and hydration. + +Verification: `go test ./internal/user/...` passed; the focused frontend user-settings and parser suite passed 52 tests; the full focused backend package run passed 3,282 tests. diff --git a/docs/plans/sidebar-automatic-task-colors/task-02-resolve-effective-task-colors.md b/docs/plans/sidebar-automatic-task-colors/task-02-resolve-effective-task-colors.md new file mode 100644 index 0000000000..c3328b1df0 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-02-resolve-effective-task-colors.md @@ -0,0 +1,116 @@ +--- +id: "02-resolve-effective-task-colors" +title: "Resolve effective task colors" +status: done +wave: 2 +depends_on: + - "01-persist-automatic-color-settings" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.9 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.10 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.11 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.12 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.14 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.15 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.8 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 02: Resolve Effective Task Colors + +## Summary + +Add the pure first-match rule engine and connect it to sidebar task rows. Preserve manual colors as the fallback source. + +## In scope + +- Rule normalization and matching for all seven dimensions. +- Shared option builders and explicit differences from sidebar filter dimensions. +- Separate manual, fixed-rule, and effective color presentation types. +- Safe workflow-step color mapping with a gray fallback. +- Desktop, mobile, and archived task fact projection with live recalculation. +- Pure repository identity matching for workspace, provider, and local targets. +- Repository metadata joins at each task projection boundary. +- Automatic-source information in the existing task color menu. + +## Out of scope + +- Repository option discovery and picker UI. +- Rule editing controls. +- Changes to task or workflow persistence. + +## Acceptance + +- The first enabled match wins for every supported dimension. +- Automatic output hides but never erases the manual value. +- Task and workflow updates change the marker without a task-color write. +- Missing origins normalize to Kanban. Missing task facts do not match their dimensions. +- The manual menu remains seven colors while fixed automatic rules support ten colors. + +## Verification + +```bash +(cd apps/web && pnpm exec vitest run lib/task-colors.test.ts lib/task-color-presentation.test.ts lib/sidebar/repository-rule-identity.test.ts lib/sidebar/task-color-rules.test.ts components/task/sidebar-filter/use-filter-value-options.test.ts components/task/sidebar-filter/task-color-rule-options.test.tsx components/task/task-session-sidebar-item.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts components/task/task-session-sidebar-archived-item.test.ts components/task/task-item.test.tsx components/task/task-switcher-context-menu.test.tsx) +``` + +## Files likely touched + +- `apps/web/lib/task-colors.ts` +- `apps/web/lib/task-color-presentation.ts` +- `apps/web/lib/task-color-presentation.test.ts` +- `apps/web/lib/sidebar/sidebar-dimension-options.ts` +- `apps/web/lib/sidebar/repository-rule-identity.ts` +- `apps/web/lib/sidebar/repository-rule-identity.test.ts` +- `apps/web/lib/sidebar/task-color-rules.ts` +- `apps/web/lib/sidebar/task-color-rules.test.ts` +- `apps/web/components/task/sidebar-filter/use-filter-value-options.ts` +- `apps/web/components/task/sidebar-filter/task-color-rule-options.ts` +- `apps/web/components/task/sidebar-filter/task-color-rule-options.test.tsx` +- `apps/web/components/settings/workflow-pipeline-editor-helpers.tsx` +- `apps/web/components/task/task-switcher-types.ts` +- `apps/web/components/task/task-session-sidebar-item.ts` +- `apps/web/components/task/task-session-sidebar.tsx` +- `apps/web/components/task/mobile/session-task-switcher-sheet-item.ts` +- `apps/web/components/task/task-session-sidebar-archived-item.ts` +- `apps/web/components/task/task-switcher-row.tsx` +- `apps/web/components/task/task-item.tsx` +- `apps/web/components/task/task-switcher-color-menu.tsx` + +## Dependencies + +Task 01 supplies normalized portable rules and repository target wire types. + +## Risks + +- Per-row subscriptions can cause broad rerenders. Derive automatic colors at the sidebar projection boundary. +- Workflow classes must map through a shared safe presentation parser. +- Omitted projection tests can let desktop, mobile, and archived behavior drift. + +## Parallelism + +`sequential` + +## Inputs + +- Rule model and color-resolution sections in the system design. +- Existing manual color store and task-row projection. + +## Results + +Implemented first-match resolution, ten-color presentation, workflow-step color parsing, repository identity matching, task facts, and automatic-source disclosure. Task 06 replaces the original device-local manual-color source. + +Verification: focused resolver, presentation, identity, projection, option, mapper, and sidebar suites passed 80 tests. Desktop and mobile Playwright flows passed with live state changes and reload persistence. diff --git a/docs/plans/sidebar-automatic-task-colors/task-03-build-repository-target-catalog.md b/docs/plans/sidebar-automatic-task-colors/task-03-build-repository-target-catalog.md new file mode 100644 index 0000000000..a881e39c35 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-03-build-repository-target-catalog.md @@ -0,0 +1,89 @@ +--- +id: "03-build-repository-target-catalog" +title: "Build repository target catalog" +status: done +wave: 3 +depends_on: + - "01-persist-automatic-color-settings" + - "02-resolve-effective-task-colors" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.8 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 03: Build Repository Target Catalog + +## Summary + +Build one repository catalog for automatic-color targets. Reuse current workspace, local, remote, and plugin discovery paths. + +## In scope + +- Stable workspace, provider, and local repository identities. +- Grouped options, duplicate removal, search, refresh, and unavailable stored targets. +- Generation guards and partial provider error state. +- A reusable view model for desktop and mobile pickers. +- Cross-workspace display rules for workspace, provider, and local targets. + +## Out of scope + +- Provider-specific API clients. +- Adding a discovered repository to a workspace. +- Final rule-card presentation. + +## Acceptance + +- One refresh reloads every source and ignores older results. +- Duplicate source records produce one stable option. +- Partial provider errors keep successful sources usable. +- Stored targets from another workspace remain visible and unavailable. + +## Verification + +```bash +(cd apps/web && pnpm exec vitest run hooks/domains/integrations/use-remote-repositories.test.tsx components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx lib/sidebar/repository-rule-catalog.test.ts lib/sidebar/repository-rule-identity.test.ts) +``` + +## Files likely touched + +- `apps/web/hooks/domains/integrations/use-remote-repositories.ts` +- `apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx` +- `apps/web/hooks/domains/workspace/use-repositories.ts` +- `apps/web/lib/state/slices/workspace/workspace-slice.ts` +- `apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts` +- `apps/web/components/task/sidebar-filter/repository-rule-catalog.ts` +- `apps/web/components/task/sidebar-filter/repository-rule-catalog.test.tsx` + +## Dependencies + +Task 01 supplies the repository target wire type. Task 02 supplies identity primitives and matching behavior. + +## Risks + +- Remote provider identities can collide without host and scope fields. +- A plugin can return a repeated cursor or malformed record. + +## Parallelism + +`sequential` + +## Inputs + +- Repository identity and discovery sections in the system design. +- Existing task-creation and add-workspace-source repository pickers. + +## Results + +Implemented the grouped repository catalog and hook across workspace, local, built-in remote, plugin, and unavailable targets. Added stable identity keys, duplicate removal, query filtering, refresh generation, and provider-error preservation for the shared desktop/mobile picker. + +Verification: repository catalog and identity tests passed as part of the 80-test focused frontend suite; the desktop and mobile Playwright flows exercised the picker and persisted target. diff --git a/docs/plans/sidebar-automatic-task-colors/task-04-deliver-responsive-automatic-color-editor.md b/docs/plans/sidebar-automatic-task-colors/task-04-deliver-responsive-automatic-color-editor.md new file mode 100644 index 0000000000..74d1b3f071 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-04-deliver-responsive-automatic-color-editor.md @@ -0,0 +1,141 @@ +--- +id: "04-deliver-responsive-automatic-color-editor" +title: "Deliver responsive automatic-color editor" +status: done +wave: 4 +depends_on: + - "02-resolve-effective-task-colors" + - "03-build-repository-target-catalog" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.11 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.12 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.13 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.14 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.15 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.16 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.17 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.18 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.19 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.20 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.9 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.10 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.11 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 04: Deliver Responsive Automatic-Color Editor + +## Summary + +Deliver the compact disclosure layout and the complete automatic-color editor. Prove the same user outcome on desktop and mobile. + +## In scope + +- Shared Sort, Group by, Task row, and Automatic colors disclosures. +- Consistent summary spacing and separators for Sort, Group by, and Task row. +- No extra primitive-level gap between adjacent editor sections. +- Global enable control, ordered rule cards, selectors, color outputs, and removal. + - Localized condition labels in rule headings, aligned condition and target rows, one selected output swatch, and a responsive evaluation-timing help icon. + - Compact disclosure padding and a bounded editor scroll region that keeps bottom actions reachable. +- Disabled incomplete-rule state, generated rule summaries, and the 50-rule limit state. +- Explicit Executor profile, Task state, Priority, Origin, and Kanban-origin options. +- Desktop repository popover and focused mobile repository pane. +- Unavailable targets from inactive workspaces. +- Localized copy in all shipped catalogs. +- Component, desktop Playwright, and mobile Playwright coverage. + +## Out of scope + +- New rule dimensions. +- Nested mobile drawers. +- Public documentation. + +## Acceptance + +- The compact editor shows accurate summaries without changing a view draft. +- Rule headings show their order and localized condition dimension, and repository targets align with condition controls. +- Rule operations remain usable with keyboard, pointer, and touch input. +- Desktop and mobile flows persist rules and recolor tasks after fact changes. +- Long editors scroll inside their owning popover or drawer, and the same help content is available by hover, focus, or tap. +- A second browser context receives the stored rule order, targets, and output colors. +- The manual color menu remains unchanged. Task 06 replaces its device-local persistence. + +## Verification + +```bash +(cd apps/web && pnpm exec vitest run components/task/sidebar-filter/sidebar-filter-popover.test.tsx components/task/sidebar-filter/task-row-settings.test.tsx components/task/sidebar-filter/sidebar-settings-disclosure.test.tsx components/task/sidebar-filter/automatic-color-settings.test.tsx components/task/sidebar-filter/task-color-rule-options.test.tsx) +(cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run tests/task/sidebar-automatic-colors.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-automatic-colors.spec.ts) +``` + +## Files likely touched + +- `apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx` +- `apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx` +- `apps/web/components/task/sidebar-filter/sidebar-settings-disclosure.tsx` +- `apps/web/components/task/sidebar-filter/task-row-settings.tsx` +- `apps/web/components/task/sidebar-filter/automatic-color-settings.tsx` +- `apps/web/components/task/sidebar-filter/automatic-color-rule-card.tsx` +- `apps/web/components/task/sidebar-filter/automatic-color-repository-picker.tsx` +- `apps/web/src/locales/*/task.json` +- `apps/web/e2e/tests/task/sidebar-automatic-colors.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts` + +## Dependencies + +Task 02 supplies effective colors, dimension options, and identity matching. Task 03 supplies repository options and source status. + +## Risks + +- Dense rule cards can overflow the 22rem desktop popover. +- Mounting the parent editor during mobile picker navigation can create two scroll owners. +- A stored target from another workspace needs a clear unavailable label without changing the rule. +- New copy must remain complete in five locales. + +## Parallelism + +`sequential` + +## Inputs + +- Editor composition and responsive sections in the system design. +- Current Task row disclosure and the drawer in `sidebar-filter-popover.tsx`. +- Focused navigation behavior from `mobile-picker-sheet.tsx` without a nested drawer. +- Mobile UI language and E2E sidebar guidance. + +## Results + +Implemented shared settings disclosures, the global automatic-color editor, ordered drag and touch actions, disabled incomplete rules, scalar and repository selectors, unavailable-target retention, localized copy, and the focused mobile repository pane. Added desktop and mobile Playwright coverage for saved rules, recoloring, repository selection, and reload persistence. + +Verification: the component suite passed; `pnpm run i18n:zh-hant` and `pnpm run i18n:check` passed; both automatic-color Playwright specs passed. + +Follow-up polish aligned the Sort, Group by, and Task row separator spacing; corrected localized condition and origin labels; removed the duplicate selected-color swatch; and added responsive hover or focus help. The compact-layout follow-up reduced shared disclosure padding, moved scope and timing copy into one help icon on both layouts, and added explicit viewport fallback scrolling. Component, localization, typecheck, desktop Playwright, and mobile Playwright verification passed. diff --git a/docs/plans/sidebar-automatic-task-colors/task-05-persist-personal-manual-colors.md b/docs/plans/sidebar-automatic-task-colors/task-05-persist-personal-manual-colors.md new file mode 100644 index 0000000000..a9c04370c6 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-05-persist-personal-manual-colors.md @@ -0,0 +1,100 @@ +--- +id: "05-persist-personal-manual-colors" +title: "Persist personal manual colors" +status: done +wave: 5 +depends_on: + - "04-deliver-responsive-automatic-color-editor" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.12 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.1 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.5 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 05: Persist Personal Manual Colors + +## Summary + +Add a backend-owned manual-color map to personal user settings. Add an atomic patch that supports normal edits, clear tombstones, and import-only-missing writes. + +## In scope + +- Go models, DTOs, controller mapping, service patch validation, stored JSON, boot state, and settings events. +- Seven valid manual color values, clear tombstones, and safe defaults for old settings. +- A bounded `sidebar_task_color_patch` request with normal and `if_missing` behavior. +- CAS retries that reapply per-task changes to the latest settings map. +- Frontend wire types, normalized settings state, and common HTTP or WebSocket mapping. + +## Out of scope + +- Reading browser storage. +- Replacing the current manual-color hook. +- New colors or changes to task records. + +## Acceptance + +- The backend stores personal colors and tombstones without changing task records. +- Normal patches affect only supplied tasks. Import patches never overwrite a color or tombstone. +- Concurrent patches for different tasks survive CAS retries without lost updates. +- One patch accepts 500 entries. The stored map accepts 10,000 entries and 1 MiB of encoded JSON. + +## Verification + +```bash +(cd apps/backend && go test ./internal/user/... ./internal/backendapp) +(cd apps/web && pnpm exec vitest run lib/ssr/user-settings.test.ts lib/ssr/user-settings-task-colors.test.ts lib/ws/handlers/users.test.ts lib/task-colors.test.ts) +``` + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/models/sidebar_task_colors.go` +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/backendapp/boot_state.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/user/**/*_test.go` +- `apps/backend/internal/backendapp/*user_settings_test.go` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/ws/handlers/users.ts` +- `apps/web/lib/task-colors.ts` + +## Dependencies + +Task 04 supplies the current color palettes, effective-color precedence, and user-settings event path. + +## Risks + +- A full-map replacement can erase an unrelated browser edit. +- A shallow settings copy can alias the stored map during change detection. +- Unbounded tombstones can enlarge every settings response. + +## Parallelism + +`sequential` + +## Inputs + +- Requirement sections for manual color ownership, migration precedence, and portability. +- System-design sections for the manual color model and mutation. +- ADR 0041 for backend-owned portable settings. + +## Results + +Implemented the personal manual-color map and bounded per-task patch across the Go model, DTO, controller, service, SQLite settings payload, boot state, and settings events. Normal edits overwrite supplied task IDs, clear edits retain tombstones, and missing-only imports preserve existing decisions. Added the web wire types, normalized hydration, and backend regression coverage. + +Verification: `go test ./internal/user/... ./internal/backendapp` passed 1,158 tests across 7 packages; the focused settings and color suite passed 88 tests. diff --git a/docs/plans/sidebar-automatic-task-colors/task-06-adopt-server-backed-manual-colors.md b/docs/plans/sidebar-automatic-task-colors/task-06-adopt-server-backed-manual-colors.md new file mode 100644 index 0000000000..8c99fdb899 --- /dev/null +++ b/docs/plans/sidebar-automatic-task-colors/task-06-adopt-server-backed-manual-colors.md @@ -0,0 +1,103 @@ +--- +id: "06-adopt-server-backed-manual-colors" +title: "Adopt server-backed manual colors" +status: done +wave: 6 +depends_on: + - "05-persist-personal-manual-colors" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005 +acceptance_criteria: + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.2 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.3 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.4 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.5 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.6 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.7 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.8 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.9 + - AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.10 +system_design: + - ../../specs/ui/system-design/sidebar-automatic-task-colors.md +--- + +# Task 06: Adopt Server-Backed Manual Colors + +## Summary + +Make backend settings the only display source for manual colors. Import valid legacy browser colors with missing-only patches, then remove the legacy value. + +## In scope + +- A settings-backed `useTaskColor` selector and optimistic `useSetTaskColor` mutation. +- Safe rollback for failed writes and revision guards for delayed responses. +- A startup migration after user settings load. +- Bounded legacy parsing and import batches. +- Legacy-key removal only after complete success. +- Localized write errors. +- Desktop and mobile browser coverage without layout changes. + +## Out of scope + +- Real-time cross-browser delivery beyond existing settings events. +- Changes to color menus, marker geometry, touch targets, or drawer composition. +- Shared task colors. + +## Acceptance + +- Fresh clients never persist manual colors in browser storage. +- Legacy colors import only into missing task IDs. Stored colors and tombstones win conflicts. +- Desktop and mobile show the backend value after reload. Failed writes restore the latest confirmed value. + +## Verification + +```bash +(cd apps/web && pnpm exec vitest run lib/task-colors.test.ts hooks/use-task-color.test.tsx hooks/use-task-color-migration.test.tsx components/task/task-item.test.tsx components/task/task-switcher-context-menu.test.tsx) +(cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run tests/task/sidebar-task-color-sync.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-sidebar-task-color-sync.spec.ts) +``` + +## Files likely touched + +- `apps/web/lib/task-colors.ts` +- `apps/web/hooks/use-task-color.ts` +- `apps/web/hooks/use-task-color.test.tsx` +- `apps/web/hooks/use-task-color-migration.ts` +- `apps/web/hooks/use-task-color-migration.test.tsx` +- `apps/web/hooks/use-ensure-user-settings.ts` +- `apps/web/components/task/task-item.tsx` +- `apps/web/components/task/task-switcher-color-menu.tsx` +- `apps/web/src/locales/*/task.json` +- `apps/web/e2e/tests/task/sidebar-task-color-sync.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-task-color-sync.spec.ts` + +## Dependencies + +Task 05 supplies the stored map, patch semantics, wire types, and common settings mapping. + +## Risks + +- A migration that starts before settings load can treat an unknown server value as missing. +- Removing the browser key before all batches succeed can lose colors that were not imported. +- A delayed HTTP response can replace a newer settings event without revision guards. + +## Parallelism + +`sequential` + +## Inputs + +- Requirement sections for portable manual colors and migration behavior. +- System-design sections for legacy migration, failure behavior, and responsive behavior. +- Existing desktop and phone task-color menus. + +## Results + +Implemented the settings-backed manual-color selector and optimistic patch mutation with serialized writes, revision guards, latest-confirmed rollback, and localized errors. Added load-gated legacy import in bounded missing-only batches, safe parsing, success-only legacy-key removal, and desktop/mobile coverage without changing the existing menu or drawer layout. + +Verification: the focused Task 06 suite passed 76 tests; `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:zh-hant`, and `pnpm run i18n:check` passed; desktop and mobile Playwright sync specs each passed one test. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 4fcb72ce3e..6ed14f5037 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -47,7 +47,7 @@ UI owns responsive behavior; other systems own behavior/state. - [Empty-Turn Notice and Slash-Command Hint](requirements/empty-turn-notice.md) - [Entity Reference Composer](requirements/entity-reference-composer.md) - [Executor settings card spacing](requirements/executor-settings-card-spacing.md) -- [External VCS File Links](requirements/external-vcs-file-links.md) +- [VCS File Links](requirements/external-vcs-file-links.md) - [File Tree Chat Context](requirements/file-tree-chat-context.md) - [File Tree Keyboard Scope](requirements/file-tree-keyboard-scope.md) - [Reload Kandev after frozen-tab restore](requirements/fix-duplicated-tab-stale-data.md) @@ -101,6 +101,7 @@ UI owns responsive behavior; other systems own behavior/state. - [Settings Manual Save](requirements/settings-manual-save.md) - [Settings Prompt Editor](requirements/settings-prompt-editor.md) - [Consistent settings typography](requirements/settings-typography.md) +- [Sidebar Automatic Task Colors](requirements/sidebar-automatic-task-colors.md) - [Sidebar Archived Task Views](requirements/sidebar-archived-filter.md) - [Sidebar Diff Stat Priority](requirements/sidebar-diff-stat-priority.md) - [Sidebar empty task alignment](requirements/sidebar-empty-task-alignment.md) @@ -121,8 +122,8 @@ UI owns responsive behavior; other systems own behavior/state. - [Task listing](requirements/task-listing-display-preferences.md) - [Task transcript history visibility](requirements/task-prompt-transcript-visibility.md) - [Task Review Shortcut Switcher](requirements/task-review-shortcut.md) -- [Task surface refresh and mobile create](requirements/task-surface-refresh.md) -- [Task Workspace Content Search](requirements/task-workspace-content-search.md) +- [Task Surface Refresh](requirements/task-surface-refresh.md) +- [Workspace Search](requirements/task-workspace-content-search.md) - [Terminal close feedback](requirements/terminal-close-feedback.md) - [Terminal Rendering](requirements/terminal-rendering.md) - [Terminal Touch Scrolling](requirements/terminal-touch-scrolling.md) @@ -163,6 +164,7 @@ UI owns responsive behavior; other systems own behavior/state. - [Mobile Task Chrome](system-design/mobile-task-chrome.md) - [Persistent status motion](system-design/persistent-status-motion.md) - [Repository Groups](system-design/sidebar-repository-grouping.md) +- [Sidebar Automatic Task Colors](system-design/sidebar-automatic-task-colors.md) - [Sidebar Task Focus](system-design/sidebar-task-focus.md) - [Sidebar task row](system-design/sidebar-task-row-presentation.md) - [PR walkthrough](system-design/pr-walkthrough.md) diff --git a/docs/specs/ui/requirements/sidebar-automatic-task-colors.md b/docs/specs/ui/requirements/sidebar-automatic-task-colors.md new file mode 100644 index 0000000000..6298a982bf --- /dev/null +++ b/docs/specs/ui/requirements/sidebar-automatic-task-colors.md @@ -0,0 +1,128 @@ +--- +status: active +system: ui +created: 2026-08-30 +owners: + - kandev +--- + +# Sidebar Task Colors Requirements + +## Overview + +The sidebar can use a personal manual task color or derive a personal color from ordered rules. Neither source changes shared task records. + +The UI system owns this presentation contract. The task, workflow, executor, and repository systems continue to own the source data. + +## Terminology + +- **Automatic color rule:** A personal condition that supplies a task color. +- **Effective color:** The first matching automatic color. If no rule matches, it is the manual color. +- **Fixed rule color:** A color token stored as part of an automatic color rule. +- **Manual color:** A portable personal color that a user assigns directly to one task. +- **Repository target:** A stable reference to a workspace, local, built-in remote, or plugin repository. + +## Requirements + +### REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001: Compact sidebar view editor + +**Intent:** Reduce the editor height without hiding the current sort, group, task-row, or automatic-color state. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.1:** The editor shall show Sort and Group by as collapsed summary rows before Task row. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.2:** Each summary shall show the current value. The Sort summary shall also show its direction. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.3:** Expanding or collapsing a summary shall not create or change a saved-view draft. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.4:** The editor shall show Automatic colors after Task row. It shall identify the setting as personal and global across sidebar views and workspaces through accessible help content. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.5:** The Automatic colors summary shall show whether automation is off or how many rules are enabled. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.6:** The collapsed Sort, Group by, and Task row summaries shall use the same compact bottom inset before their separator, and adjacent editor sections shall not add an extra primitive-level gap. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001.7:** When editor content exceeds the available viewport, the surface shall use one vertical scroll owner and keep every setting and action reachable. + +### REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002: Ordered personal color rules + +**Intent:** Let a user recognize task categories without changing colors for other users. + +**User story:** As a user, I want task colors to follow task data, so that the sidebar stays organized as tasks change. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.1:** A user shall be able to enable, disable, add, remove, and reorder automatic color rules. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.2:** The first enabled matching rule shall supply the effective color. Later matching rules shall have no effect. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.3:** Rules shall support workflow step, repository, workflow, executor profile, task state, priority, and origin conditions. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.4:** If the step color is supported, a workflow-step rule shall use the current visible step color. Otherwise, it shall use gray. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.5:** If a task fact, rule order, output, or enabled state changes, the rule shall update the effective color. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.6:** If the required facts are available, rules shall apply to existing, new, active, and archived sidebar tasks. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.7:** A matching automatic rule shall take precedence over a manual color without erasing the manual value. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.8:** When no rule matches, the task shall show its manual color or no marker. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.9:** The task color menu shall identify an active automatic source and explain that manual colors do not override matching rules. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.10:** Automatic colors shall not mutate a task, workflow, executor, repository, or workspace setting. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.11:** Fixed rule colors shall support gray, red, orange, yellow, green, cyan, blue, indigo, purple, and pink. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.12:** The manual color menu shall keep its current seven colors. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.13:** An incomplete rule shall be disabled, shall match no task, and shall remain editable. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.14:** An origin rule shall offer Kanban as the value for a task that has no stored origin. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.15:** A task-state rule shall offer every raw task state. It shall not use the broader state groups from sidebar filters. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.16:** Condition selectors shall show localized human-readable labels for each supported dimension and origin value. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.17:** A fixed-color output selector shall show one color swatch for its current selection. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.18:** The editor shall explain that rules apply to existing and new sidebar tasks and reevaluate when relevant task facts or rule settings change through a focusable information icon. Desktop shall open the help on pointer hover and keyboard focus. Phone and tablet shall open the same help on focus or tap. No required information or behavior shall depend on hover. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.19:** Each rule heading shall show its order and the localized name of its selected condition dimension. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002.20:** Condition and target fields shall share aligned label and control rows in the desktop two-column layout, including repository targets without a selected or available repository. + +### REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003: Repository rule targets + +**Intent:** Let repository rules cover current workspace sources and future task sources. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.1:** The repository picker shall search workspace repositories and discovered local repositories. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.2:** The picker shall also search built-in remote providers and registered plugin repository providers. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.3:** The picker shall provide one refresh action and show source-specific loading, empty, unavailable, and error states. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.4:** A user shall be able to select a discoverable repository before the repository joins a workspace. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.5:** If any attached repository has the selected stable identity, the repository rule shall match. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.6:** An unavailable target shall remain editable and shall not match another repository with the same display name. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.7:** A workspace target shall not match a task from another workspace. It shall appear unavailable while another workspace is active. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003.8:** A provider or local target shall match in each workspace that contains the same stable repository identity. + +### REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004: Portable and responsive settings + +**Intent:** Keep rule behavior consistent across devices and input methods. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.1:** The enabled state and ordered rules shall persist in backend-owned personal settings. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.2:** Successful edits shall store rule order, targets, and output colors in backend settings. They shall appear after reload and in another browser for the same user. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.3:** A failed settings write shall restore the latest confirmed rules and show a localized error. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.4:** Missing or malformed settings shall resolve to disabled automation with no rules. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.5:** Desktop shall use the anchored popover with a viewport-bounded vertical scroll region and viewport-contained pickers. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.6:** Phone and tablet shall use the existing safe-area-aware drawer with one vertical scroll owner when content exceeds the drawer height. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.7:** The mobile repository flow shall stay inside the current drawer instead of opening a nested drawer. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.8:** Touch rows, reorder controls, and standalone actions shall have a 44 CSS pixel target. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.9:** Rule order and repository selection shall support touch, pointer, and keyboard input. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.10:** The editor shall not cause document-level horizontal overflow at supported widths. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004.11:** The editor shall disable Add rule at 50 rules and shall show a localized limit message. + +### REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005: Portable personal manual colors + +**Intent:** Keep a user's manual task colors consistent across browsers without changing colors for other users. + +**User story:** As a user, I want my manual task colors in each browser, so that I can recognize tasks consistently. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.1:** A manual color shall persist as a personal setting. It shall not change the shared task or another user's color. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.2:** After another browser loads or reloads, it shall show the latest stored manual colors for that user. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.3:** A successful manual color edit shall remain after reload on desktop, phone, and tablet. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.4:** During upgrade, valid legacy browser colors shall import only for tasks without a stored color decision. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.5:** A stored color or stored clear decision shall take precedence over every legacy browser value for that task. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.6:** A successful legacy import shall remove the legacy browser value. A failed import shall keep it only for a later import attempt. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.7:** The application shall not use a legacy browser value as the displayed color before the backend accepts it. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.8:** A failed manual color edit shall restore the latest confirmed backend value and show a localized error. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.9:** A matching automatic rule shall hide the stored manual color without changing or clearing it. +- **AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005.10:** An already-open browser shall receive the latest stored color no later than its next reload. Immediate cross-browser updates are not required. + +## Out of scope + +- Shared workspace colors or a task-owned color field. +- Rules for labels, projects, assigned agents, age, staleness, subtasks, pull requests, or CI status. +- Changes to workflow-step colors or executor configuration. +- Import or export of rule sets. +- Real-time delivery of manual color changes to other open browsers. diff --git a/docs/specs/ui/system-design/sidebar-automatic-task-colors.md b/docs/specs/ui/system-design/sidebar-automatic-task-colors.md new file mode 100644 index 0000000000..1fe88c5214 --- /dev/null +++ b/docs/specs/ui/system-design/sidebar-automatic-task-colors.md @@ -0,0 +1,376 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004 + - REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005 +--- + +# Sidebar Task Colors System Design + +## Purpose and boundaries + +The UI system owns automatic task colors as a personal presentation contract. The feature reads task facts but never writes task records. + +The backend owns the portable settings value. Existing task, workflow, executor, repository, and plugin APIs remain the fact sources. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-001` | [Editor composition](#editor-composition) | +| `REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-002` | [Rule model](#rule-model), [Color resolution](#color-resolution) | +| `REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-003` | [Repository identity and discovery](#repository-identity-and-discovery) | +| `REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-004` | [Settings persistence](#settings-persistence), [Responsive behavior](#responsive-behavior), [Failure behavior](#failure-behavior) | +| `REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-005` | [Manual color model and mutation](#manual-color-model-and-mutation), [Legacy browser migration](#legacy-browser-migration), [Failure behavior](#failure-behavior) | + +## Existing contracts + +- `TaskDTO` supplies workflow, step, state, priority, origin, primary executor, and ordered task-repository links. +- Workflow snapshots supply workflow names, step names, and stored step-color values. +- `useRepositories` supplies saved workspace repositories and their provider identities. +- `useRemoteRepositories` supplies built-in and plugin-provider repositories. +- Workspace repository discovery supplies local Git repositories that are not yet saved. +- `filter-dimension-registry.ts`, `use-filter-value-options.ts`, and `apply-view.ts` own the current sidebar filter contract. +- `task-session-sidebar-item.ts`, `session-task-switcher-sheet-item.ts`, and `task-session-sidebar-archived-item.ts` build task-row projections. +- `lib/task-colors.ts` defines the legacy browser format and the seven-color manual palette. +- `PATCH /api/v1/user/settings` remains the only durable write path for personal portable settings. + +## Rule model + +The user settings object adds `sidebar_task_color_automation`: + +```json +{ + "enabled": true, + "rules": [ + { + "id": "rule-uuid", + "enabled": true, + "condition": { + "dimension": "task_state", + "value": "BLOCKED", + "label": "Blocked" + }, + "output": { + "kind": "fixed", + "color": "red" + } + } + ] +} +``` + +The settings object is a complete replacement value. A missing object resolves to `{ "enabled": false, "rules": [] }`. + +Each rule has one condition and one output. The supported dimensions are: + +- `workflow_step` +- `repository` +- `workflow` +- `executor_profile` +- `task_state` +- `priority` +- `origin` + +The `value` field stores a dimension-specific target or `null`. The `label` field stores display text for an unavailable target. + +An incomplete rule has a `null` value and `enabled: false`. The backend preserves this rule. The resolver never matches it. + +The output is either `{ "kind": "fixed", "color": "red" }` or `{ "kind": "workflow_step" }`. The second output is valid only for a workflow-step condition. + +The fixed color keys are `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `indigo`, `purple`, and `pink`. + +The backend accepts at most 50 rules. It applies these bounds: + +- 64 UTF-8 bytes for a rule ID +- 200 Unicode code points for a stored label +- 512 UTF-8 bytes for each entity ID, provider ID, host, scope, or provider repository ID +- 4096 UTF-8 bytes for a normalized local path + +The backend rejects duplicate or empty rule IDs, unknown dimensions, oversized fields, malformed targets, and invalid outputs. An enabled rule must have a complete target. + +## Dimension contracts and option sources + +If values have the same meaning, the automatic-color editor reuses option builders from the sidebar filter editor. A shared `SidebarDimensionOption` model carries values, labels, colors, groups, and unavailable state. + +The implementation extracts reusable workflow and step option builders from `use-filter-value-options.ts`. Filter operators and filter matching remain in `filter-dimension-registry.ts` and `apply-view.ts`. + +Some rule dimensions differ from filters by product design: + +| Rule dimension | Stored target | Option source | Difference from sidebar filters | +| --- | --- | --- | --- | +| `workflow_step` | workspace ID and step ID | Current workspace workflow snapshots | The workspace ID prevents cross-workspace collisions. | +| `repository` | Typed repository target | Repository rule catalog | A rule matches any attached repository. The filter keeps its primary repository slug behavior. | +| `workflow` | workspace ID and workflow ID | Current workspace workflow snapshots | The workspace ID prevents cross-workspace collisions. | +| `executor_profile` | Executor profile ID | Executor profiles from settings state | The filter continues to match `executorType`. The rule UI uses the label Executor profile. | +| `task_state` | Raw `TaskState` value | Static translated task-state list | The filter continues to use the broader review, in-progress, and backlog groups. | +| `priority` | `TaskPriority` value | Static translated priority list | This dimension has no filter equivalent. | +| `origin` | `TaskOrigin` value or `kanban` | Static translated origin list | A missing task origin normalizes to `kanban`. | + +If another workspace is active, stored labels keep workspace-scoped targets understandable. A generated localized summary identifies a rule. The model does not persist a rule name. + +## Color presentation contract + +`TaskColor` remains the seven-color manual palette in `lib/task-colors.ts`. The manual menu does not add gray, cyan, or indigo. + +`FixedAutomaticTaskColor` contains the ten fixed rule keys. The shared presentation registry maps gray to `bg-slate-500`. It maps every other key to its matching `bg-*-500` class. + +`TaskMarkerPresentation` separates a stored color token from its rendered class or inline color. `TaskItem` consumes this safe presentation value. + +The workflow-step output reads the current step color at resolution time. The shared parser supports these inputs: + +- every value in the workflow step color catalog +- canonical color names returned by host or plugin projections +- explicitly listed legacy workflow classes, including `bg-neutral-400` and `bg-emerald-500` +- strict hexadecimal color literals in three, four, six, or eight digit form + +The parser maps catalog values and legacy classes through static entries. It renders a valid hexadecimal value as an inline background color. It uses gray for every unsupported value. + +The frontend never renders a rule value as a CSS class. New task-locale keys supply gray, cyan, and indigo labels for the rule editor in all shipped locales. + +## Color resolution + +`resolveAutomaticTaskColor` is a pure function. It receives normalized rules and these task facts: + +- workspace ID +- workflow ID +- workflow-step ID and color +- task state +- task priority +- task origin +- primary executor profile ID +- attached repository identities + +The function scans enabled rules in stored order. It returns the first matching output. + +A fixed output returns its safe presentation value. A workflow-step output uses the shared workflow color parser. + +An unsupported step color produces gray. A task with no primary executor profile does not match an executor-profile rule. + +A missing task origin normalizes to `kanban`. State rules compare raw `TaskState` values instead of sidebar filter groups. + +If any attached repository matches the target, the repository condition matches. Repository order does not change this result. + +`TaskSwitcherItem` adds workspace ID, priority, normalized origin, primary executor profile ID, workflow-step color, and attached repository identities. + +`buildSidebarItem` and `toSheetItem` copy priority, origin, and executor profile ID from the task projection. Their contexts join repository links to full repository records and map step IDs to step colors. + +`buildArchivedSidebarItem` copies facts that the archived task projection contains. A fact that is absent does not match its rule dimension. + +Desktop and mobile derive automatic colors from the same `TaskSwitcherItem` facts. Task, workflow, repository, and settings updates rebuild the affected presentation. + +`TaskItem` reads the manual color from normalized user settings. It renders `automaticColor ?? manualColor`. + +This composition preserves the manual value. If automation stops matching, the previous manual marker becomes visible again. + +The task color context menu reads both sources. For an automatic effective color, it shows the generated rule summary. + +## Repository identity and discovery + +A repository condition uses one typed target: + +| Kind | Stable fields | Match source | +| --- | --- | --- | +| `workspace` | `workspace_id`, `repository_id` | Exact saved repository ID in that workspace | +| `provider` | provider ID, host, scope, provider repository ID | Saved remote or plugin repository identity | +| `local` | normalized absolute path | Saved or discovered local repository path | + +The picker selects the strongest available identity. Provider identity has priority over workspace ID. Local path is used for a local-only repository. + +Provider matching includes the host and scope. This rule prevents collisions between providers or self-hosted instances. + +A workspace target matches only tasks from its stored workspace. If the stable identity is equal, a provider or local target can match across workspaces. + +The repository picker composes existing sources through one catalog hook. It does not call provider APIs directly. + +The catalog contains: + +- saved repositories from the active workspace +- discovered local repositories +- GitHub, GitLab, and Azure DevOps repositories +- repositories from registered plugin providers + +The live catalog searches the active workspace. A stored target from another workspace stays in its rule with its stored label. The editor marks that target unavailable until its workspace becomes active. + +The catalog removes duplicate options by stable identity. It keeps source groups and source-specific status. + +Search filters saved and local options in memory. It also sends the debounced query to plugin providers that support remote search. + +Refresh increments one catalog generation. The generation reloads saved, local, built-in remote, and plugin sources. + +Late results from an older generation do not replace a newer result. One provider error does not hide successful sources. + +A removed provider or unavailable local path leaves the stored rule intact. The editor uses the stored label and marks the target unavailable. + +## Settings persistence + +Backend user settings are the durable owner, as required by ADR 0041. The backend stores `sidebar_task_color_automation` inside the `users.settings` JSON value. No new database column or schema migration is required. + +The Go user model, DTOs, controller mapping, service patch, stored JSON, HTTP response, boot payload, and settings event carry the complete object. + +The frontend wire type and `UserSettingsState` carry normalized rules. `mapUserSettingsData` remains the common ingestion path. + +The stored object contains the enabled state, rule order, targets, stored labels, and selected outputs. It does not store one derived color per task. + +Automatic-color edits are global across saved views and workspaces. They are independent of a saved-view draft. The editor states this rule next to the control. + +HTTP hydration, the boot payload, settings events, and mutation responses use the same backend value. The rules and fixed colors therefore follow the user to another browser. + +Manual task colors are stored in backend-owned personal settings. They follow the same user to each browser. + +A serialized optimistic mutation helper owns writes. Each operation applies to the latest local rule list and sends one complete replacement. + +The helper maps the authoritative response through the common settings mapper. User-settings revisions reject older HTTP or WebSocket snapshots. + +## Manual color model and mutation + +The stored settings add `sidebar_task_colors`. This map uses a task ID as its key. + +Each value is one of the seven `TaskColor` values or `null`. A color value is an active manual color. A `null` value is a clear tombstone. + +The tombstone is not a visible color. It prevents a later legacy import from restoring a color that the user cleared. + +The settings response includes only normalized values. The frontend derives its visible manual-color map from the non-null values. + +The settings update request adds a narrow `sidebar_task_color_patch` operation. The operation contains a bounded map and an `if_missing` flag. + +For a normal edit, `if_missing` is false. The backend overwrites only the supplied task IDs. + +For a clear operation, the supplied value is `null`. The backend stores a tombstone for that task ID. + +For a legacy import, `if_missing` is true. The backend adds an entry only when the stored map has no key for that task ID. + +The service applies each patch to the latest settings value during every CAS attempt. Concurrent writes to different task IDs cannot replace each other. + +If two normal edits change the same task, the last committed edit wins. A legacy import never wins against a color or tombstone. + +The backend accepts at most 500 entries in one patch. A task ID contains at most 128 UTF-8 bytes. + +The stored map contains at most 10,000 entries and 1 MiB of encoded JSON. These limits keep settings responses below the existing size budget. + +The backend stores the map in the existing `users.settings` JSON value. This change does not add a task column or a database schema migration. + +`useTaskColor` selects the confirmed color from `UserSettingsState`. `useSetTaskColor` applies an optimistic per-task change and sends one narrow patch. + +The mutation response goes through the common settings mapper. Revision guards prevent an older response from replacing a newer settings event. + +The existing `user.settings.updated` event can refresh another open browser. The product contract only guarantees the new value after reload. + +## Legacy browser migration + +The frontend starts legacy migration only after backend user settings are loaded. It reads `kandev.taskColors` as migration input. + +The migration parser accepts only known color values and bounded task IDs. Malformed entries do not enter the request. + +The frontend sends valid entries in bounded batches with `if_missing` set to true. Each response replaces the local settings snapshot through the common mapper. + +After all batches succeed, the frontend removes `kandev.taskColors`. If no valid entries exist, it removes the legacy value without a request. + +If a batch fails, the frontend keeps the legacy value for a later load. It does not use that value to color a task. + +Tombstones remain while legacy import support exists. A later cleanup can remove tombstones only after the application retires all legacy imports. + +## Editor composition + +`SidebarSettingsDisclosure` is a shared collapsed-row primitive. Sort, Group by, Task row, and Automatic colors use this primitive. + +Each disclosure has a label, summary, chevron, stable content ID, and `aria-expanded`. Disclosure state is transient and starts collapsed. + +Sort, Group by, and Task row each own their bottom separator. The shared disclosure padding is `px-2 pb-1 pt-1`, so each collapsed toggle has the same compact inset before and after its separator. The desktop popover overrides its primitive flex gap so adjacent editor sections remain contiguous. Automatic colors follows Task row without adding a second adjacent separator. + +The Sort summary shows the selected field and direction. Its expanded content keeps the current `SortPicker`. + +The Group by summary shows the selected group. Its expanded content keeps the current `GroupPicker`. + +The Automatic colors summary shows Off or an enabled-rule count. Its expanded content contains: + +- a global enable switch +- a focusable information icon containing scope, precedence, and evaluation timing +- ordered rule cards +- an Add rule action + +The information content states that rules apply to existing and new sidebar tasks, explains personal and global scope, describes first-match precedence, and lists the task facts that trigger reevaluation. The same icon is available in the desktop popover and the phone or tablet drawer. Desktop opens it on hover or focus; the drawer also opens it on focus or tap, so no required information depends on hover. + +A rule card heading shows its order and localized condition dimension, for example Rule 1 Origin. The card shows the condition, target, output color, enabled state, reorder control, and remove action. The condition and target fields use the same label and control rows in the desktop two-column layout, including an incomplete repository target. The first matching rule is visually first. + +The workflow-step rule offers Use step color. Other rule types use a fixed safe color. Dimension and origin options use localized human-readable labels. A fixed output selection renders the swatch supplied by its selected option exactly once. + +Add rule creates a disabled task-state rule with no target and a fixed blue output. The user can change each field before enabling it. + +If the user changes a rule dimension, the editor clears its target and disables the rule. An incomplete rule shows Complete this rule and matches no task. + +At 50 rules, Add rule is disabled. The editor shows a localized limit message. + +Deleted workflows, steps, executors, and repositories remain visible as unavailable targets. The user can edit or remove those rules. + +## Responsive behavior + +Desktop keeps the current anchored sidebar popover. Its height is bounded by the Radix available height with a `100dvh` fallback, and it owns vertical scrolling when the editor is taller than the viewport. Selectors use viewport-contained popovers with searchable command lists. + +Phone and tablet keep the current inset drawer in `sidebar-filter-popover.tsx`. This existing surface is the nearest mobile exemplar. Its header stays fixed and its editor body remains the only vertical scroll owner with overscroll containment. + +The repository picker uses a focused pane inside the open drawer. It follows the focused navigation pattern from `mobile-picker-sheet.tsx`. A Back action returns to the rule card without opening another drawer. + +Desktop and mobile share rule state, normalization, matching, catalog data, and actions. Only the picker composition changes. + +Desktop and mobile also share the server-backed manual-color hook. The existing menus, markers, drawer, touch targets, and scroll ownership do not change. + +Touch summary rows, reorder controls, standalone actions, and the information trigger have a 44 CSS pixel active dimension. No action depends on hover. Desktop help supports hover and keyboard focus; the drawer uses the same help on keyboard focus or tap. + +## Failure behavior + +If a settings write fails and the failed operation is still current, the mutation helper restores the latest confirmed settings. + +The sidebar sync toast shows a localized error. A failed global write does not change the saved-view draft. + +If one repository source fails, the picker keeps successful groups. It shows the error beside the failed source and permits refresh. + +If stored automation data is malformed, the frontend disables automation and drops invalid rules. It preserves valid disabled incomplete rules and does not change manual colors. + +If a manual color write fails, the frontend restores the latest confirmed color and shows a localized error. + +If a legacy import fails, the backend map remains authoritative. The frontend retains the legacy value only as input for the next import attempt. + +If stored manual-color data is malformed, the backend omits invalid entries and keeps valid colors and tombstones. It does not reject all user settings. + +## Accessibility and security + +Color is decorative task organization. Existing state icons, titles, labels, and group headings continue to carry meaning. + +Rule summaries include text. A swatch is never the only description of a color or condition. + +Reorder supports pointer, touch, and keyboard sensors with an accessible announcement. Focus returns to the changed rule after picker dismissal. + +The backend bounds list size and text length. Repository targets contain credential-free identity only. + +Plugin repository results remain untrusted data. The catalog treats their labels and URLs as text and validates their required identity fields. + +## Tests + +Backend tests cover defaults, exact bounds, incomplete-rule validation, JSON round trips, partial patches, settings revisions, and HTTP responses. + +Frontend unit tests cover shared option sources, dimension differences, all rule dimensions, precedence, manual fallback, and step-color fallbacks. + +Backend tests cover normal color edits, clear tombstones, import-only-missing behavior, CAS retries, limits, and malformed stored entries. + +Frontend tests cover settings mapping, optimistic rollback, legacy parsing, successful removal, failed retry retention, and stale-response rejection. + +Projection tests cover desktop, mobile, and archived task facts. They also cover repository metadata joins and missing facts. + +Catalog tests cover search, refresh generations, source errors, duplicate identities, plugin reload, and unavailable stored targets. + +Component tests cover disclosure summaries and separators, global and timing copy, localized condition labels, single output swatches, rule editing, reorder, automatic-source copy, and desktop or mobile picker composition. + +Desktop Playwright coverage proves manual-color migration and persistence across browser contexts. It also proves rule precedence, repository search, live recoloring, readable condition options, single output swatches, aligned disclosure spacing, and hover help. + +Mobile Playwright coverage proves server-backed manual colors through the drawer. It also proves visible timing guidance, touch targets, internal scrolling, and no horizontal overflow. + +## Related decisions and designs + +- [ADR 0041: Backend-Owned Portable User Settings](../../../decisions/0041-backend-owned-portable-user-settings.md) +- [Sidebar Task Row Presentation](sidebar-task-row-presentation.md) +- [Sidebar Task Focus](sidebar-task-focus.md)