feat(sidebar): add automatic task color rules (#3180)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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" });
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user