feat(sidebar): add automatic task color rules (#3180)

This commit is contained in:
Carlos Florêncio
2026-09-06 17:18:09 +01:00
committed by GitHub
parent 5965a6592c
commit 3173c2b56a
128 changed files with 8381 additions and 290 deletions
+15 -9
View File
@@ -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
@@ -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),
@@ -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{
@@ -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",
+9 -1
View File
@@ -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,
@@ -17,6 +17,7 @@ func TestFromTaskWithSessionInfoIncludesPrimaryAgentProfileID(t *testing.T) {
nil,
nil,
nil,
nil,
&profileID,
nil,
nil,
@@ -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
@@ -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,
@@ -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 {
@@ -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 != "" {
@@ -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(),
@@ -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())
+10
View File
@@ -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,
@@ -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")
@@ -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)
@@ -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"`
@@ -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": {},
}
@@ -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")
}
}
@@ -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": {},
}
@@ -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"])
}
}
@@ -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,
@@ -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) {
@@ -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
}
@@ -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)
}
@@ -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
}
}
}
@@ -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)
}
}
+34 -1
View File
@@ -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
@@ -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) {
@@ -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,
};
}
@@ -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<ReturnType<typeof useAppStoreApi>["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,
@@ -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<string, string | undefined>;
@@ -17,8 +21,15 @@ export type SheetItemCtx = {
wipQueueByTaskId?: Map<string, WipQueueStatus>;
acknowledgedAgentErrors?: Record<string, string>;
dismissedAgentErrors?: Record<string, string>;
workspaceId?: string;
repositoriesById?: ReadonlyMap<string, Repository>;
stepColorById?: ReadonlyMap<string, string>;
automaticColorSettings?: SidebarTaskColorAutomation;
};
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
const EMPTY_STEP_COLORS = new Map<string, string>();
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),
};
@@ -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: {} },
}),
}),
}));
@@ -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, unknown>) => string;
const GROUP_LABEL_KEYS: Record<RepositoryRuleCatalogOption["group"], string> = {
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 = (
<Button
type="button"
variant="outline"
className="min-h-11 w-full min-w-0 justify-start truncate text-xs md:min-h-0 md:h-7"
onClick={onOpen}
data-testid={`automatic-color-repository-trigger-${rule.id}`}
aria-label={t("task:automaticColorsSelectRepository")}
>
{selectedOption?.label ?? t("task:automaticColorsSelectRepository")}
</Button>
);
const fieldLabel = (
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t("task:automaticColorsSelectRepository")}
</span>
);
const field = (
<div className="min-w-0">
{fieldLabel}
{button}
</div>
);
if (isDrawerLayout) return field;
return (
<div className="min-w-0">
{fieldLabel}
<Popover>
<PopoverTrigger asChild>{button}</PopoverTrigger>
<PopoverContent align="start" className="w-[20rem] p-2">
<RepositoryPicker
options={options}
query={query}
loading={loading}
error={error}
onQueryChange={onQueryChange}
onRefresh={onRefresh}
onSelect={onSelect}
isDrawerLayout={false}
t={t}
/>
</PopoverContent>
</Popover>
</div>
);
}
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 (
<div className="space-y-2" data-testid="automatic-color-repository-pane">
<Button
type="button"
variant="ghost"
className="min-h-11 w-full cursor-pointer justify-start px-1 text-xs"
onClick={onBack}
data-testid="automatic-color-repository-back"
>
<IconChevronLeft className="mr-1 size-4" aria-hidden="true" />
{t("task:automaticColorsBackToRule")}
</Button>
<RepositoryPicker
options={options}
query={query}
loading={loading}
error={error}
onQueryChange={onQueryChange}
onRefresh={onRefresh}
onSelect={onSelect}
isDrawerLayout
t={t}
/>
</div>
);
}
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 (
<div className="space-y-2" data-testid="automatic-color-repository-picker">
<div className="flex items-center gap-1">
<Input
value={query}
onChange={(event) => 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"
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-11 shrink-0 cursor-pointer md:size-8"
onClick={onRefresh}
disabled={loading}
aria-label={t("task:automaticColorsRefreshRepositories")}
data-testid="automatic-color-repository-refresh"
>
<IconRefresh className={cn("size-3.5", loading && "animate-spin")} aria-hidden="true" />
</Button>
</div>
{error && (
<p className="text-[11px] text-muted-foreground" role="status">
{t("task:automaticColorsRepositoryError")}
</p>
)}
{loading && <p className="text-xs text-muted-foreground">{t("task:loading")}</p>}
{!loading && groups.length === 0 && (
<p className="text-xs text-muted-foreground">{t("task:automaticColorsNoRepositories")}</p>
)}
<div className={cn("space-y-2", !isDrawerLayout && "max-h-[18rem] overflow-y-auto")}>
{groups.map((group) => (
<div key={group}>
<p className="px-1 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(GROUP_LABEL_KEYS[group])}
</p>
<div className="space-y-0.5">
{options
.filter((option) => option.group === group)
.map((option) => (
<button
key={option.key}
type="button"
className="flex min-h-11 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60 md:min-h-8"
disabled={!option.available}
onClick={() => onSelect(option)}
data-testid={`automatic-color-repository-option-${option.key}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{option.label}</span>
{option.secondaryLabel && (
<span className="block truncate text-[11px] text-muted-foreground">
{option.secondaryLabel}
</span>
)}
</span>
{!option.available && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("task:automaticColorsUnavailable")}
</span>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}
@@ -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 (
<div
ref={sortable.setNodeRef}
style={{
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
}}
className="min-w-0 rounded-md border border-border/60 p-2"
data-testid={`automatic-color-rule-${rule.id}`}
data-dragging={sortable.isDragging ? "true" : undefined}
>
<AutomaticColorRuleHeader
ruleId={rule.id}
index={index}
total={total}
dimension={rule.condition.dimension}
sortable={sortable}
onRemove={onRemove}
onMove={onMove}
t={t}
/>
<div className="space-y-2 pl-0 md:pl-11">
<AutomaticColorConditionFields
rule={rule}
options={options}
repositoryOptions={repositoryOptions}
selectedOption={selectedOption}
isDrawerLayout={isDrawerLayout}
repositoryQuery={repositoryQuery}
repositoryLoading={repositoryLoading}
repositoryError={repositoryError}
onRepositoryQueryChange={onRepositoryQueryChange}
onRefreshRepositories={onRefreshRepositories}
onOpenRepository={onOpenRepository}
onChange={onChange}
onConditionChange={(dimension) => onChange(ruleWithCondition(rule, dimension))}
onTargetChange={(key) => {
const next = ruleWithTarget(rule, options, key);
if (next) onChange(next);
}}
t={t}
/>
<AutomaticColorOutputField rule={rule} onChange={onChange} t={t} />
<AutomaticColorEnableField
rule={rule}
complete={complete}
onChange={(enabled) => onChange({ ...rule, enabled })}
t={t}
/>
</div>
</div>
);
}
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<typeof useSortable>;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
t: Translate;
}) {
return (
<div className="flex min-h-11 items-center gap-1">
<button
type="button"
className="flex min-h-11 min-w-11 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("task:automaticColorsReorderHandle", { number: index + 1 })}
data-testid={`automatic-color-rule-handle-${ruleId}`}
{...sortable.attributes}
{...sortable.listeners}
aria-roledescription={t("task:automaticColorsReorderable")}
>
<IconGripVertical className="size-4" aria-hidden="true" />
</button>
<span className="min-w-0 flex-1 text-xs font-medium">
{t("task:automaticColorsRule", { number: index + 1 })}{" "}
{t(taskColorDimensionLabelKey(dimension))}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 cursor-pointer md:size-7"
onClick={() => onMove(-1)}
disabled={index === 0}
aria-label={t("task:automaticColorsMoveUp")}
data-testid={`automatic-color-rule-up-${ruleId}`}
>
<IconArrowUp className="size-3.5" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 cursor-pointer md:size-7"
onClick={() => onMove(1)}
disabled={index === total - 1}
aria-label={t("task:automaticColorsMoveDown")}
data-testid={`automatic-color-rule-down-${ruleId}`}
>
<IconArrowDown className="size-3.5" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 cursor-pointer text-muted-foreground hover:text-destructive md:size-7"
onClick={onRemove}
aria-label={t("task:automaticColorsRemoveRule")}
data-testid={`automatic-color-rule-remove-${ruleId}`}
>
<IconTrash className="size-3.5" aria-hidden="true" />
</Button>
</div>
);
}
@@ -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<FixedAutomaticTaskColor, string>;
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 (
<div className="grid min-w-0 gap-2 md:grid-cols-2">
<AutomaticColorDimensionField
ruleId={rule.id}
dimension={rule.condition.dimension}
onChange={onConditionChange}
t={t}
/>
{rule.condition.dimension === "repository" ? (
<RepositoryConditionField
rule={rule}
selectedOption={selectedOption}
isDrawerLayout={isDrawerLayout}
options={repositoryOptions}
query={repositoryQuery}
loading={repositoryLoading}
error={repositoryError}
onQueryChange={onRepositoryQueryChange}
onRefresh={onRefreshRepositories}
onOpen={onOpenRepository}
onSelect={(option) =>
onChange({
...rule,
condition: { ...rule.condition, value: option.target, label: option.label },
})
}
t={t}
/>
) : (
<AutomaticColorTargetField
ruleId={rule.id}
options={options}
selectedOption={selectedOption}
onChange={onTargetChange}
t={t}
/>
)}
</div>
);
}
function AutomaticColorDimensionField({
ruleId,
dimension,
onChange,
t,
}: {
ruleId: string;
dimension: SidebarTaskColorDimension;
onChange: (dimension: SidebarTaskColorDimension) => void;
t: Translate;
}) {
return (
<label className="min-w-0">
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t("task:automaticColorsCondition")}
</span>
<Select
value={dimension}
onValueChange={(value) => onChange(value as SidebarTaskColorDimension)}
>
<SelectTrigger
size="sm"
className="min-h-11 w-full text-xs md:min-h-0 md:h-7"
data-testid={`automatic-color-dimension-${ruleId}`}
aria-label={t("task:automaticColorsCondition")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTOMATIC_TASK_COLOR_DIMENSIONS.map((option) => (
<SelectItem key={option} value={option} className="text-xs">
{t(taskColorDimensionLabelKey(option))}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
function AutomaticColorTargetField({
ruleId,
options,
selectedOption,
onChange,
t,
}: {
ruleId: string;
options: readonly TaskColorRuleOption[];
selectedOption: TaskColorRuleOption | undefined;
onChange: (key: string) => void;
t: Translate;
}) {
return (
<label className="min-w-0">
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t("task:automaticColorsSelectTarget")}
</span>
<Select value={selectedOption?.key} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="min-h-11 w-full text-xs md:min-h-0 md:h-7"
data-testid={`automatic-color-target-${ruleId}`}
aria-label={t("task:automaticColorsSelectTarget")}
>
<SelectValue placeholder={t("task:automaticColorsSelectTarget")} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.key}
value={option.key}
disabled={!option.available}
className="text-xs"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
export function AutomaticColorOutputField({
rule,
onChange,
t,
}: {
rule: SidebarTaskColorRule;
onChange: (rule: SidebarTaskColorRule) => void;
t: Translate;
}) {
return (
<label className="block min-w-0">
<span className="mb-1 block text-[11px] font-medium text-muted-foreground">
{t("task:automaticColorsOutput")}
</span>
<Select
value={rule.output.kind === "workflow_step" ? "workflow_step" : rule.output.color}
onValueChange={(output) => {
if (output === "workflow_step") {
if (rule.condition.dimension === "workflow_step") {
onChange({ ...rule, output: { kind: "workflow_step" } });
}
return;
}
const color = output as FixedAutomaticTaskColor;
if (FIXED_AUTOMATIC_TASK_COLORS.includes(color)) {
onChange({ ...rule, output: { kind: "fixed", color } });
}
}}
>
<SelectTrigger
size="sm"
className="min-h-11 w-full text-xs md:min-h-0 md:h-7"
data-testid={`automatic-color-output-${rule.id}`}
aria-label={t("task:automaticColorsOutput")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{rule.condition.dimension === "workflow_step" && (
<SelectItem value="workflow_step" className="text-xs">
{t("task:automaticColorsUseStepColor")}
</SelectItem>
)}
{FIXED_AUTOMATIC_TASK_COLORS.map((color) => (
<SelectItem key={color} value={color} className="text-xs">
<span className="flex items-center gap-2">
<ColorSwatch marker={fixedAutomaticTaskColor(color)} />
{t(AUTOMATIC_COLOR_LABEL_KEYS[color])}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
export function AutomaticColorEnableField({
rule,
complete,
onChange,
t,
}: {
rule: SidebarTaskColorRule;
complete: boolean;
onChange: (enabled: boolean) => void;
t: Translate;
}) {
return (
<div className="flex min-h-11 items-center justify-between gap-2 rounded-md bg-muted/30 px-2">
<div className="min-w-0">
<span className="block text-xs font-medium">{t("task:automaticColorsEnable")}</span>
{!complete && (
<span className="block text-[11px] text-muted-foreground">
{t("task:automaticColorsCompleteRule")}
</span>
)}
</div>
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
<Switch
checked={rule.enabled}
disabled={!complete}
onCheckedChange={onChange}
aria-label={t("task:automaticColorsEnable")}
data-testid={`automatic-color-rule-enabled-${rule.id}`}
/>
</label>
</div>
);
}
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 (
<span
className={cn(
"inline-block size-2.5 shrink-0 rounded-full",
marker.token === "custom" ? undefined : marker.className,
)}
style={marker.token === "custom" ? marker.style : undefined}
aria-hidden="true"
/>
);
}
@@ -0,0 +1,99 @@
"use client";
import {
DndContext,
KeyboardSensor,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { SidebarTaskColorRule } from "@/lib/task-color-automation-settings";
import type { RepositoryRuleCatalogOption } from "@/lib/sidebar/repository-rule-catalog";
import type { TaskColorRuleOptionMap } from "./task-color-rule-options";
import { AutomaticColorRuleCard } from "./automatic-color-rule-card";
import type { Translate } from "./automatic-color-repository-picker";
export function AutomaticColorRuleList({
rules,
scalarOptions,
repositoryOptions,
repositoryQuery,
repositoryLoading,
repositoryError,
onRepositoryQueryChange,
onRefreshRepositories,
isDrawerLayout,
onChange,
onRemove,
onOpenRepository,
onReorder,
t,
}: {
rules: SidebarTaskColorRule[];
scalarOptions: TaskColorRuleOptionMap;
repositoryOptions: readonly RepositoryRuleCatalogOption[];
repositoryQuery: string;
repositoryLoading: boolean;
repositoryError: Error | null;
onRepositoryQueryChange: (query: string) => void;
onRefreshRepositories: () => void;
isDrawerLayout: boolean;
onChange: (ruleId: string, rule: SidebarTaskColorRule) => void;
onRemove: (ruleId: string) => void;
onOpenRepository: (ruleId: string) => void;
onReorder: (activeId: string, overId: string) => void;
t: Translate;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event: DragEndEvent) {
if (event.over) onReorder(String(event.active.id), String(event.over.id));
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((rule) => rule.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2" data-testid="automatic-color-rule-list">
{rules.map((rule, index) => (
<AutomaticColorRuleCard
key={rule.id}
rule={rule}
index={index}
total={rules.length}
scalarOptions={scalarOptions}
repositoryOptions={repositoryOptions}
repositoryQuery={repositoryQuery}
repositoryLoading={repositoryLoading}
repositoryError={repositoryError}
onRepositoryQueryChange={onRepositoryQueryChange}
onRefreshRepositories={onRefreshRepositories}
isDrawerLayout={isDrawerLayout}
onChange={(next) => onChange(rule.id, next)}
onRemove={() => onRemove(rule.id)}
onOpenRepository={() => onOpenRepository(rule.id)}
onMove={(direction) => {
const destination = index + direction;
if (destination >= 0 && destination < rules.length) {
onReorder(rule.id, rules[destination]!.id);
}
}}
t={t}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,10 @@
import type { SidebarTaskColorRule } from "@/lib/task-color-automation-settings";
export function createAutomaticColorRule(number: number): SidebarTaskColorRule {
return {
id: `automatic-color-${Date.now().toString(36)}-${number}`,
enabled: false,
condition: { dimension: "task_state", value: null, label: "" },
output: { kind: "fixed", color: "blue" },
};
}
@@ -0,0 +1,224 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SidebarTaskColorAutomation } from "@/lib/task-color-automation-settings";
const mocks = vi.hoisted(() => ({
value: { enabled: false, rules: [] } as SidebarTaskColorAutomation,
update: vi.fn(),
}));
vi.mock("./use-sidebar-task-color-automation", () => ({
useSidebarTaskColorAutomation: () => ({
value: mocks.value,
update: mocks.update,
saving: false,
error: null,
}),
}));
vi.mock("./task-color-rule-options", () => ({
taskColorDimensionLabelKey: (dimension: string) =>
({
workflow_step: "task:automaticColorsDimensionWorkflowStep",
repository: "task:automaticColorsDimensionRepository",
workflow: "task:automaticColorsDimensionWorkflow",
executor_profile: "task:automaticColorsDimensionExecutorProfile",
task_state: "task:automaticColorsDimensionTaskState",
priority: "task:automaticColorsDimensionPriority",
origin: "task:automaticColorsDimensionOrigin",
})[dimension] ?? `task:${dimension}`,
taskColorRuleOptionKey: (value: unknown) => JSON.stringify(value),
useTaskColorRuleOptions: () => ({
workflow_step: [],
repository: [],
workflow: [],
executor_profile: [],
task_state: [{ key: JSON.stringify("TODO"), value: "TODO", label: "To do", available: true }],
priority: [],
origin: [],
}),
}));
vi.mock("./use-repository-rule-catalog", () => ({
useRepositoryRuleCatalog: () => ({
options: [],
query: "",
setQuery: vi.fn(),
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
workspaces: { activeId: "workspace-1" },
kanbanMulti: { snapshots: {} },
workflows: { items: [] },
executors: { items: [] },
}),
}));
import { AutomaticColorSettings } from "./automatic-color-settings";
const SETTINGS_TOGGLE_TEST_ID = "automatic-color-settings-toggle";
function renderSettings(isDrawerLayout = false) {
return render(
<TooltipProvider>
<AutomaticColorSettings isDrawerLayout={isDrawerLayout} />
</TooltipProvider>,
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
mocks.value = { enabled: false, rules: [] };
});
describe("AutomaticColorSettings timing guidance", () => {
it("keeps scope and timing guidance behind a focusable info tooltip", () => {
const { rerender } = renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
expect(screen.getByTestId("automatic-colors-help").getAttribute("aria-label")).toBe(
"When automatic colors apply",
);
expect(screen.queryByTestId("automatic-colors-timing")).toBeNull();
expect(
screen.queryByText("Personal setting. Applies across sidebar views and workspaces.", {
exact: true,
}),
).toBeNull();
expect(
screen.queryByText("The first matching rule wins. Manual colors remain as fallback.", {
exact: true,
}),
).toBeNull();
rerender(
<TooltipProvider>
<AutomaticColorSettings isDrawerLayout />
</TooltipProvider>,
);
expect(screen.getByTestId("automatic-colors-help")).toBeTruthy();
expect(screen.queryByTestId("automatic-colors-timing")).toBeNull();
});
});
describe("AutomaticColorSettings rule headings", () => {
it("includes the localized condition in each rule heading", () => {
mocks.value = {
enabled: true,
rules: [
{
id: "origin-rule",
enabled: false,
condition: { dimension: "origin", value: "kanban", label: "Kanban" },
output: { kind: "fixed", color: "red" },
},
],
};
renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
expect(screen.getByText("Rule 1 Origin", { exact: true })).toBeTruthy();
});
});
describe("AutomaticColorSettings", () => {
it("adds a disabled incomplete rule without enabling the global setting", () => {
renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
fireEvent.click(screen.getByTestId("automatic-color-add-rule"));
expect(mocks.update).toHaveBeenCalledWith({
enabled: false,
rules: [
expect.objectContaining({
enabled: false,
condition: { dimension: "task_state", value: null, label: "" },
output: { kind: "fixed", color: "blue" },
}),
],
});
});
it("keeps an enabled rule editable when its target is unavailable", () => {
mocks.value = {
enabled: true,
rules: [
{
id: "missing",
enabled: true,
condition: { dimension: "task_state", value: "IN_PROGRESS", label: "In progress" },
output: { kind: "fixed", color: "red" },
},
],
};
renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
const enabledSwitch = screen.getByTestId(
"automatic-color-rule-enabled-missing",
) as HTMLButtonElement;
expect(enabledSwitch.disabled).toBe(false);
fireEvent.click(enabledSwitch);
expect(mocks.update).toHaveBeenCalledWith({
enabled: true,
rules: [expect.objectContaining({ id: "missing", enabled: false })],
});
});
it("shows one color swatch in the selected fixed output", () => {
mocks.value = {
enabled: true,
rules: [
{
id: "fixed-output",
enabled: true,
condition: { dimension: "task_state", value: "TODO", label: "To do" },
output: { kind: "fixed", color: "red" },
},
],
};
renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
const output = screen.getByTestId("automatic-color-output-fixed-output");
expect(output.querySelectorAll("span.inline-block.rounded-full")).toHaveLength(1);
});
it("keeps an incomplete rule disabled until its target is available", () => {
mocks.value = {
enabled: true,
rules: [
{
id: "todo",
enabled: false,
condition: { dimension: "task_state", value: null, label: "" },
output: { kind: "fixed", color: "red" },
},
],
};
renderSettings();
fireEvent.click(screen.getByTestId(SETTINGS_TOGGLE_TEST_ID));
expect(
(screen.getByTestId("automatic-color-rule-enabled-todo") as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(screen.getByTestId("automatic-color-target-todo"));
fireEvent.click(screen.getByRole("option", { name: "To do" }));
expect(mocks.update).toHaveBeenCalledWith({
enabled: true,
rules: [
expect.objectContaining({
condition: { dimension: "task_state", value: "TODO", label: "To do" },
}),
],
});
});
});
@@ -0,0 +1,204 @@
"use client";
import { IconInfoCircle, IconPalette, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { MAX_AUTOMATIC_TASK_COLOR_RULES } from "@/lib/task-color-automation-settings";
import { SidebarSettingsDisclosure } from "./sidebar-settings-disclosure";
import { AutomaticColorRuleList } from "./automatic-color-rule-list";
import { RepositoryPickerPane } from "./automatic-color-repository-picker";
import {
useAutomaticColorSettingsController,
type AutomaticColorSettingsController,
} from "./use-automatic-color-settings-controller";
type Props = {
isDrawerLayout: boolean;
};
export function AutomaticColorSettings({ isDrawerLayout }: Props) {
const { t } = useTranslation();
const controller = useAutomaticColorSettingsController();
const enabledRuleCount = controller.value.rules.filter((rule) => rule.enabled).length;
const summary = controller.value.enabled
? t("task:automaticColorsSummaryRules", { count: enabledRuleCount })
: t("task:automaticColorsSummaryOff");
return (
<SidebarSettingsDisclosure
title={
<span className="flex items-center gap-1.5">
<IconPalette className="size-3.5" aria-hidden="true" />
{t("task:automaticColors")}
</span>
}
summary={summary}
testId="automatic-color-settings"
expanded={controller.expanded}
onExpandedChange={controller.setExpanded}
contentClassName="space-y-2 pt-1"
>
<AutomaticColorSettingsBody controller={controller} isDrawerLayout={isDrawerLayout} t={t} />
</SidebarSettingsDisclosure>
);
}
function AutomaticColorSettingsBody({
controller,
isDrawerLayout,
t,
}: {
controller: AutomaticColorSettingsController;
isDrawerLayout: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const activeRule = controller.activeRepositoryRule;
const catalog = controller.repositoryCatalog;
return (
<>
<div className="rounded-md border border-border/60 p-2" aria-busy={controller.saving}>
<div className="flex min-h-11 items-center gap-2">
<div className="min-w-0 flex-1">
<span className="flex items-center gap-1 text-xs font-medium">
{t("task:automaticColorsEnabled")}
<AutomaticColorsInfo t={t} />
</span>
</div>
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
<Switch
checked={controller.value.enabled}
onCheckedChange={(enabled) => controller.updateSettings(undefined, enabled)}
aria-label={t("task:automaticColorsEnabled")}
data-testid="automatic-color-enabled"
/>
</label>
</div>
</div>
{controller.error && (
<p
role="alert"
className="px-1 text-xs text-destructive"
data-testid="automatic-color-error"
>
{controller.error}
</p>
)}
{isDrawerLayout && activeRule ? (
<RepositoryPickerPane
options={catalog.options}
query={controller.repositoryQuery}
loading={catalog.loading}
error={catalog.error}
onQueryChange={controller.setRepositorySearch}
onRefresh={catalog.refresh}
onBack={controller.closeRepository}
onSelect={(option) => {
controller.updateRule(activeRule.id, {
...activeRule,
condition: {
...activeRule.condition,
value: option.target,
label: option.label,
},
});
controller.closeRepository();
}}
/>
) : (
<AutomaticColorRules controller={controller} isDrawerLayout={isDrawerLayout} t={t} />
)}
</>
);
}
function AutomaticColorsInfo({
t,
}: {
t: (key: string, options?: Record<string, unknown>) => string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("task:automaticColorsHelpLabel")}
className="relative inline-flex size-11 shrink-0 cursor-help items-center justify-center text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:size-5 md:after:absolute md:after:-inset-2"
data-testid="automatic-colors-help"
>
<IconInfoCircle className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[280px]">
<div className="space-y-2">
<p>{t("task:automaticColorsPersonalGlobal")}</p>
<p>{t("task:automaticColorsTiming")}</p>
<p>{t("task:automaticColorsPrecedence")}</p>
<p>{t("task:automaticColorsHelp")}</p>
</div>
</TooltipContent>
</Tooltip>
);
}
function AutomaticColorRules({
controller,
isDrawerLayout,
t,
}: {
controller: AutomaticColorSettingsController;
isDrawerLayout: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const catalog = controller.repositoryCatalog;
return (
<>
{controller.value.rules.length > 0 && (
<AutomaticColorRuleList
rules={controller.value.rules}
scalarOptions={controller.scalarOptions}
repositoryOptions={catalog.options}
repositoryQuery={controller.repositoryQuery}
repositoryLoading={catalog.loading}
repositoryError={catalog.error}
onRepositoryQueryChange={controller.setRepositorySearch}
onRefreshRepositories={catalog.refresh}
isDrawerLayout={isDrawerLayout}
onChange={controller.updateRule}
onRemove={controller.removeRule}
onOpenRepository={controller.openRepository}
onReorder={controller.reorderRules}
t={t}
/>
)}
<div className="flex items-center justify-between gap-2">
<span className="px-1 text-[11px] text-muted-foreground">
{t("task:automaticColorsRuleCount", { count: controller.value.rules.length })}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 cursor-pointer text-xs md:min-h-0 md:h-7"
onClick={controller.addRule}
disabled={
controller.value.rules.length >= MAX_AUTOMATIC_TASK_COLOR_RULES || controller.saving
}
data-testid="automatic-color-add-rule"
>
<IconPlus className="mr-1 size-3.5" aria-hidden="true" />
{t("task:automaticColorsAddRule")}
</Button>
</div>
{controller.value.rules.length >= MAX_AUTOMATIC_TASK_COLOR_RULES && (
<p className="px-1 text-[11px] text-muted-foreground">
{t("task:automaticColorsRuleLimit")}
</p>
)}
</>
);
}
@@ -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(
<SidebarFilterPopover
trigger={<button type="button">Open</button>}
@@ -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(
<SidebarFilterPopover
trigger={<button type="button">Open</button>}
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(
<SidebarFilterPopover
trigger={<button type="button">Open</button>}
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." },
@@ -81,7 +81,8 @@ export function SidebarFilterPopover({
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto p-0"
className="max-h-[var(--radix-popper-available-height)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto gap-0 p-0"
style={{ maxHeight: "var(--radix-popper-available-height, calc(100dvh - 1rem))" }}
align="end"
data-testid="sidebar-filter-popover"
>
@@ -103,7 +104,7 @@ export function SidebarFilterPopover({
</DrawerHeader>
<div
data-testid="sidebar-filter-popover"
className="min-h-0 flex-1 overflow-y-auto pb-[calc(1rem+env(safe-area-inset-bottom))]"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
>
{editor}
</div>
@@ -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(
<SidebarSettingsDisclosure title="Settings" testId="settings">
<span>Content</span>
</SidebarSettingsDisclosure>,
);
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(
<SidebarSettingsDisclosure
title="Settings"
testId="settings"
expanded={false}
onExpandedChange={onExpandedChange}
>
<span>Content</span>
</SidebarSettingsDisclosure>,
);
fireEvent.click(screen.getByTestId("settings-toggle"));
expect(onExpandedChange).toHaveBeenCalledWith(true);
expect(screen.queryByText("Content")).toBeNull();
});
});
@@ -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 (
<section className={cn("px-2 pb-1 pt-1", className)} data-testid={testId}>
<Button
type="button"
variant="ghost"
className="flex min-h-11 w-full items-center justify-between px-1 text-left"
aria-expanded={expanded}
aria-controls={contentId}
data-testid={`${testId}-toggle`}
onClick={() => setExpanded(!expanded)}
>
<span className="min-w-0 pt-1">
<span className="block text-[11px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
{title}
</span>
{summary && (
<span className="mt-1 block truncate text-xs text-muted-foreground">{summary}</span>
)}
</span>
<IconChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180",
)}
aria-hidden="true"
/>
</Button>
{expanded && (
<div id={contentId} className={contentClassName}>
{children}
</div>
)}
</section>
);
}
@@ -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 (
<>
<div className="border-b p-2">
@@ -58,19 +67,30 @@ export function SidebarViewEditor({
onChange={onChangeClause}
onRemove={onRemoveClause}
/>
<div className={`border-b px-2 pb-2 ${isDrawerLayout ? "pt-2" : "pt-0"}`}>
<SectionLabel isDrawerLayout={isDrawerLayout}>{t("task:sort")}</SectionLabel>
<SidebarSettingsDisclosure
title={t("task:sort")}
summary={sortSummary}
testId="sidebar-sort-settings"
className="border-b"
contentClassName="pt-1"
>
<SortPicker value={current.sort} onChange={(sort) => onUpdate({ sort })} />
</div>
<div className={`px-2 pb-2 ${isDrawerLayout ? "pt-2" : "pt-0"}`}>
<SectionLabel isDrawerLayout={isDrawerLayout}>{t("task:groupBy")}</SectionLabel>
</SidebarSettingsDisclosure>
<SidebarSettingsDisclosure
title={t("task:groupBy")}
summary={t(GROUP_SUMMARY_KEYS[current.group])}
testId="sidebar-group-settings"
className="border-b"
contentClassName="pt-1"
>
<GroupPicker value={current.group} onChange={(group) => onUpdate({ group })} />
</div>
</SidebarSettingsDisclosure>
<TaskRowSettings
value={current.taskRow}
sort={current.sort}
onChange={(taskRow) => onUpdate({ taskRow })}
/>
<AutomaticColorSettings isDrawerLayout={isDrawerLayout} />
</>
);
}
@@ -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 (
<span className={`${isDrawerLayout ? "" : "-mt-1 "}mb-1 block ${SECTION_LABEL_CLASS}`}>
{children}
</span>
);
}
const GROUP_SUMMARY_KEYS: Record<GroupKey, string> = {
none: "task:groupNone",
repository: "task:groupRepository",
workflow: "task:groupWorkflow",
workflowStep: "task:groupWorkflowStep",
executorType: "task:groupExecutorType",
state: "task:groupState",
};
function FilterSection({
filters,
@@ -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;
@@ -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" }),
]);
});
});
@@ -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<SidebarTaskColorDimension, TaskColorRuleOption[]>;
type Snapshots = AppState["kanbanMulti"]["snapshots"];
type Workflow = AppState["workflows"]["items"][number];
type ExecutorProfileOption = Pick<ExecutorProfile, "id" | "name">;
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<SidebarTaskColorDimension, string> = {
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<TaskState, string> = {
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<TaskPriority, string> = {
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];
}
@@ -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<SidebarTaskRowDetail, string> = {
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 (
<section className="border-t px-2 pb-2 pt-2" data-testid="task-row-settings">
<Button
type="button"
variant="ghost"
className="flex min-h-11 w-full items-center justify-between px-1 text-left"
aria-expanded={expanded}
aria-controls="task-row-settings-content"
data-testid="task-row-settings-toggle"
onClick={() => setExpanded((current) => !current)}
>
<span className="min-w-0">
<span className="block text-[11px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
{t("task:taskRow")}
</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">
{value.detailsEnabled ? detailCountLabel : t("task:taskRowDetailsHidden")} {" · "}
{trailingLabel}
<SidebarSettingsDisclosure
title={t("task:taskRow")}
summary={
<span className="flex min-w-0 items-center gap-1">
<span className="truncate">
{value.detailsEnabled ? detailCountLabel : t("task:taskRowDetailsHidden")}
</span>
<span className="truncate">{trailingLabel}</span>
</span>
<IconChevronDown
className={`size-4 shrink-0 text-muted-foreground transition-transform ${expanded ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</Button>
{expanded && (
<div id="task-row-settings-content" className="space-y-2 pt-1">
<TaskRowDetailsSection value={value} sort={sort} onChange={onChange} />
<TaskRowTrailingSelect value={value} onChange={onChange} />
</div>
)}
</section>
}
testId="task-row-settings"
className="border-b"
contentClassName="space-y-2 pt-1"
>
<TaskRowDetailsSection value={value} sort={sort} onChange={onChange} />
<TaskRowTrailingSelect value={value} onChange={onChange} />
</SidebarSettingsDisclosure>
);
}
@@ -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<string | null>(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 }];
});
}
@@ -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",
});
});
});
@@ -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<RepositoryRuleCatalogOption[]>(
() =>
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,
};
}
@@ -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<string | null>(null);
const sync = useMemo(
() =>
createQueuedUserSettingsSyncWithResponse<SidebarTaskColorAutomation>(
(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<typeof useSidebarTaskColorAutomation>;
@@ -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;
/**
+24 -4
View File
@@ -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",
)}
>
<SelectionBar isSelected={isSelected} color={taskColor} />
<SelectionBar
isSelected={isSelected}
color={resolveTaskItemColor(automaticColor, manualColor)}
/>
<RowConnector depth={indent.depth} leftPx={indent.connectorLeftPx} />
<TaskStateIcon
sessionState={sessionState}
@@ -458,16 +469,25 @@ function RowConnector({ depth, leftPx }: { depth: number; leftPx: number }) {
);
}
function SelectionBar({ isSelected, color }: { isSelected: boolean; color: TaskColor | null }) {
function SelectionBar({
isSelected,
color,
}: {
isSelected: boolean;
color: (TaskMarkerPresentation | { token: TaskColor; className: string }) | null;
}) {
if (!color) return null;
return (
<div
data-testid="task-item-color-marker"
data-color-token={color.token}
className={cn(
"absolute left-0 top-0 bottom-0 w-[3px] transition-opacity",
TASK_COLOR_BAR_CLASS[color],
color.token === "custom" ? undefined : color.className,
isSelected ? "opacity-100" : "opacity-60",
)}
style={color.token === "custom" ? color.style : undefined}
/>
);
}
@@ -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,
@@ -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" });
});
});
@@ -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<typeof useArchivedTaskState>,
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"),
@@ -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<string, string | undefined>;
titleById: Map<string, string>;
workflowNameById: Map<string, string>;
@@ -15,8 +18,15 @@ type SidebarItemContext = {
wipQueueByTaskId?: Map<string, WipQueueStatus>;
acknowledgedAgentErrors?: Record<string, string>;
dismissedAgentErrors?: Record<string, string>;
workspaceId?: string;
repositoriesById?: ReadonlyMap<string, Repository>;
stepColorById?: ReadonlyMap<string, string>;
automaticColorSettings?: SidebarTaskColorAutomation;
};
const EMPTY_REPOSITORIES_BY_ID = new Map<string, Repository>();
const EMPTY_STEP_COLORS = new Map<string, string>();
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,
};
@@ -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 {
@@ -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?:
)}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
{automaticColorSource && (
<>
<ContextMenuItem disabled data-testid="automatic-task-color-source">
{t("task:automaticColorSource", { rule: automaticColorSource.label })}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{TASK_COLORS.map((color) => (
<TaskColorMenuItem
key={color}
@@ -307,7 +307,13 @@ function SingleSelectionMenuItems({
onArchiveTask={onArchiveTask}
onBulkArchive={onBulkArchive}
/>
{!task.isArchived && <TaskColorMenu taskId={task.id} disabled={isDeleting} />}
{!task.isArchived && (
<TaskColorMenu
taskId={task.id}
disabled={isDeleting}
automaticColorSource={task.automaticColorSource}
/>
)}
<TaskNestContextMenuItems task={task} disabled={isDeleting} />
<TaskPluginPrimaryMenuItems task={task} disabled={isDeleting} />
<TaskPluginLinkMenu
@@ -170,6 +170,7 @@ function TaskRowItem({
remoteExecutorName={task.remoteExecutorName}
taskId={task.id}
workflowStepId={task.workflowStepId}
automaticColor={task.automaticColor}
primarySessionId={task.primarySessionId ?? null}
hasPendingClarification={task.hasPendingClarification}
hasPendingPermission={task.hasPendingPermission}
@@ -1,5 +1,6 @@
import type {
ForegroundActivity,
TaskOrigin,
TaskPriority,
TaskSessionState,
TaskState,
@@ -8,6 +9,9 @@ import type { GroupedSidebarList } from "@/lib/sidebar/apply-view";
import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
import type { SidebarTaskRowPresentation } from "@/lib/state/slices/ui/sidebar-task-row-presentation";
import type { TaskMarkerPresentation } from "@/lib/task-color-presentation";
import type { AutomaticTaskColorSource } from "@/lib/sidebar/task-color-rules";
import type { TaskRepositoryRuleIdentity } from "@/lib/sidebar/repository-rule-identity";
export type StepDef = {
id: string;
@@ -34,8 +38,15 @@ export type TaskSwitcherItem = {
workflowName?: string;
workflowStepId?: string;
workflowStepTitle?: string;
workspaceId?: string;
origin?: TaskOrigin | string;
primaryExecutorProfileId?: string;
workflowStepColor?: string;
repositoryPath?: string;
repositories?: string[];
repositoryRuleIdentities?: readonly TaskRepositoryRuleIdentity[];
automaticColor?: TaskMarkerPresentation;
automaticColorSource?: AutomaticTaskColorSource;
/** Persisted task-to-repository links used by host-owned plugin task actions. */
repositoryLinks?: Array<{ repository_id: string; position?: number }>;
diffStats?: { additions: number; deletions: number };
+1
View File
@@ -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: [] },
});
});
});
+4
View File
@@ -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<string, string[]>;
workflow_ids_with_auto_hide_empty_steps?: string[];
sidebar_task_color_automation?: SidebarTaskColorAutomation;
sidebar_task_color_patch?: SidebarTaskColorPatchApi;
}): Promise<void> {
await this.request("PATCH", "/api/v1/user/settings", settings);
}
@@ -167,6 +167,7 @@ export class SidebarFilterPopoverPage {
}
async setSort(keyLabel: string, direction?: "asc" | "desc"): Promise<void> {
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<void> {
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<void> {
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<void> {
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");
}
@@ -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);
});
});
@@ -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();
@@ -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");
});
});
@@ -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." },
@@ -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);
});
});
@@ -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");
@@ -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<string, string | null> | 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<string, string | null> | 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<string, string | null> | 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();
});
});
@@ -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);
});
@@ -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<RemoteReposit
type RemoteRepositoryLoad = {
repos: RemoteRepository[];
availableProviders: RemoteRepositoryProvider[];
sourceErrors: RemoteRepositorySourceError[];
};
export type RemoteRepositorySourceError = {
provider: RemoteRepositoryProvider;
error: Error;
};
type RepositoryRequest = {
@@ -72,6 +80,7 @@ type RepositoryRequest = {
};
async function loadBuiltInRepositories(workspaceId: string): Promise<RemoteRepositoryLoad> {
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<RemoteRepositoryLoad> {
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<RemoteRepository[]>([]);
const [loading, setLoading] = useState(true);
const [sourceErrors, setSourceErrors] = useState<RemoteRepositorySourceError[]>([]);
const [availableProviders, setAvailableProviders] = useState<RemoteRepositoryProvider[]>([]);
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<RemoteRepository[]>([]);
const [loading, setLoading] = useState(true);
const [sourceErrors, setSourceErrors] = useState<RemoteRepositorySourceError[]>([]);
const [availableProviders, setAvailableProviders] = useState<RemoteRepositoryProvider[]>([]);
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<RemoteRepository[]>([]);
const [pluginRepos, setPluginRepos] = useState<RemoteRepository[]>([]);
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 250);
const [builtInsLoading, setBuiltInsLoading] = useState(true);
const [pluginsLoading, setPluginsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [builtInProviders, setBuiltInProviders] = useState<RemoteRepositoryProvider[]>([]);
const [pluginProviderIds, setPluginProviderIds] = useState<RemoteRepositoryProvider[]>([]);
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,
};
}
@@ -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";
@@ -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,
@@ -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<string, "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" | null>,
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<typeof useAppStoreApi> | 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 (
<StateProvider
initialState={{
userSettings: {
...defaultState.userSettings,
loaded: true,
revision: 1,
sidebarTaskColors: { "task-existing": "red", "task-cleared": null },
},
}}
>
<CaptureStore />
{children}
</StateProvider>
);
}
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<UserSettingsResponse>((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 }) => (
<StateProvider
initialState={{
userSettings: { ...defaultState.userSettings, loaded: false },
}}
>
{children}
</StateProvider>
),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
act(() => {
rerender();
});
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
@@ -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<void> | null = null;
export function chunkTaskColors(
colors: Record<string, TaskColor>,
batchSize = TASK_COLOR_MIGRATION_BATCH_SIZE,
): Array<Record<string, TaskColor>> {
const entries = Object.entries(colors);
const batches: Array<Record<string, TaskColor>> = [];
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<AppState>): Promise<void> {
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]);
}
+164
View File
@@ -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<string, "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "pink" | null>,
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<typeof useAppStoreApi> | null = null;
function CaptureStore() {
capturedStore = useAppStoreApi();
return null;
}
function wrapper({ children }: { children: React.ReactNode }) {
return (
<StateProvider
initialState={{
userSettings: {
...defaultState.userSettings,
loaded: true,
revision: 1,
sidebarTaskColors: { "task-1": "red" },
},
}}
>
<ToastProvider>
<CaptureStore />
{children}
</ToastProvider>
</StateProvider>
);
}
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<UserSettingsResponse>((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<UserSettingsResponse>((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"));
});
});
+136 -22
View File
@@ -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<string, TaskColor | null>;
function cloneTaskColorMap(colors: TaskColorMap): TaskColorMap {
return { ...colors };
}
function optimisticTaskColorSettings(
settings: ReturnType<ReturnType<typeof useAppStoreApi>["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<TaskColorMap>(cloneTaskColorMap(settings.sidebarTaskColors));
const confirmedRevisionRef = useRef<number | null>(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],
);
}
+16 -6
View File
@@ -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),
@@ -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>): 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>): 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,
},
]);
});
});
@@ -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<string>();
const add = (option: Omit<RepositoryRuleCatalogOption, "key" | "available">) => {
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);
}
@@ -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: "/" });
});
});
@@ -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<string, unknown>;
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 "";
}
}
@@ -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" } });
});
});
@@ -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<string, Repository>;
stepColorById: ReadonlyMap<string, string>;
};
export function taskColorFacts(
task: ProjectedTask,
context: Pick<TaskColorProjectionContext, "workspaceId" | "repositoriesById" | "stepColorById">,
): 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<string, Repository>,
): 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,
});
});
}
@@ -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();
});
});
+107
View File
@@ -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<SidebarTaskColorDimension, RuleMatcher> = {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+10 -1
View File
@@ -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<AppState> {
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<AppState> {
// 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<AppState> {
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,
@@ -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" });
});
});
+29
View File
@@ -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);
+14
View File
@@ -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,
+3 -1
View File
@@ -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;
@@ -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<string, TaskColor | null>;
taskCreateLastUsed: TaskCreateLastUsedState;
jiraSavedViews: unknown;
jiraTaskPresets: unknown;
@@ -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,
);
}
});
});
@@ -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<string>();
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<string>): 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<string, unknown>,
ids: Set<string>,
): { 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<string, unknown>,
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<string, unknown>,
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<SidebarTaskColorDimension, ScalarTargetDimension>;
const SCALAR_TARGET_DIMENSIONS: readonly ScalarTargetDimension[] = [
"executor_profile",
"task_state",
"priority",
"origin",
];
const STRUCTURED_TARGET_VALIDATORS: Record<StructuredTargetDimension, (value: unknown) => 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<string, string> {
return (
isRecord(value) &&
hasExactStringFields(value, fields) &&
Object.values(value).every((field) => byteLength(field) <= maxBytes)
);
}
function hasExactStringFields(
value: Record<string, unknown>,
fields: string[],
): value is Record<string, string> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -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"));
});
});
+103
View File
@@ -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<FixedAutomaticTaskColor, string> = {
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<string, FixedAutomaticTaskColor> = {
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<string, FixedAutomaticTaskColor> = {
"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");
}

Some files were not shown because too many files have changed in this diff Show More