diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 4f45c33e73..222a1f9e1e 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -629,12 +629,18 @@ func mapAppStatusBarOrder(order usermodels.AppStatusBarOrder) map[string]any { } func mapTaskCreateLastUsed(value usermodels.TaskCreateLastUsed) map[string]any { + workflowIDsByWorkspace := value.WorkflowIDsByWorkspace + if workflowIDsByWorkspace == nil { + workflowIDsByWorkspace = map[string]string{} + } return map[string]any{ - "repositoryId": nullString(value.RepositoryID), - "branch": nullString(value.Branch), - "agentProfileId": nullString(value.AgentProfileID), - "executorProfileId": nullString(value.ExecutorProfileID), - "synced": value.RepositoryID != "" || value.Branch != "" || value.AgentProfileID != "" || value.ExecutorProfileID != "", + "repositoryId": nullString(value.RepositoryID), + "branch": nullString(value.Branch), + "agentProfileId": nullString(value.AgentProfileID), + "executorProfileId": nullString(value.ExecutorProfileID), + "workflowIdsByWorkspace": workflowIDsByWorkspace, + "synced": value.RepositoryID != "" || value.Branch != "" || value.AgentProfileID != "" || + value.ExecutorProfileID != "" || len(workflowIDsByWorkspace) > 0, } } diff --git a/apps/backend/internal/backendapp/boot_state_routes_test.go b/apps/backend/internal/backendapp/boot_state_routes_test.go index 79bb157e09..8bf0bd2527 100644 --- a/apps/backend/internal/backendapp/boot_state_routes_test.go +++ b/apps/backend/internal/backendapp/boot_state_routes_test.go @@ -59,10 +59,11 @@ func TestMapUserSettingsStateIncludesPortableTaskAndSidebarSettings(t *testing.T SubtaskOrderByParentID: map[string][]string{"task-1": {"task-3"}}, }, TaskCreateLastUsed: usermodels.TaskCreateLastUsed{ - RepositoryID: "repo-1", - Branch: "main", - AgentProfileID: "agent-1", - ExecutorProfileID: "executor-1", + RepositoryID: "repo-1", + Branch: "main", + AgentProfileID: "agent-1", + ExecutorProfileID: "executor-1", + WorkflowIDsByWorkspace: map[string]string{"workspace-1": "workflow-1"}, }, }, }, "workspace-1") @@ -82,6 +83,10 @@ func TestMapUserSettingsStateIncludesPortableTaskAndSidebarSettings(t *testing.T if !ok || lastUsed["repositoryId"] != "repo-1" || lastUsed["synced"] != true { t.Fatalf("taskCreateLastUsed = %#v, want mapped settings", state["taskCreateLastUsed"]) } + workflowIDs, ok := lastUsed["workflowIdsByWorkspace"].(map[string]string) + if !ok || workflowIDs["workspace-1"] != "workflow-1" { + t.Fatalf("workflowIdsByWorkspace = %#v, want workspace-1 mapping", lastUsed["workflowIdsByWorkspace"]) + } } func TestMapUserSettingsStateNormalizesNilSubtaskOrder(t *testing.T) { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index dd3ceed518..740cace03b 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -976,6 +976,9 @@ func buildTaskCreateLastUsedPatch(body httpCreateTaskRequest, repos []dto.TaskRe AgentProfileID: body.AgentProfileID, ExecutorProfileID: body.ExecutorProfileID, } + if body.WorkspaceID != "" && body.WorkflowID != "" { + patch.WorkflowIDsByWorkspace = map[string]string{body.WorkspaceID: body.WorkflowID} + } for i, repo := range repos { if repo.RepositoryID == "" { continue diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index 8c8c5f8135..e32bdf44a6 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -560,10 +560,11 @@ func TestHTTPCreateTaskRecordsFinalLastUsedSelections(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) require.Equal(t, 1, recorder.calls) assert.Equal(t, usermodels.TaskCreateLastUsed{ - RepositoryID: "repo-2", - Branch: "feature/current", - AgentProfileID: "agent-2", - ExecutorProfileID: "exec-profile-2", + RepositoryID: "repo-2", + Branch: "feature/current", + AgentProfileID: "agent-2", + ExecutorProfileID: "exec-profile-2", + WorkflowIDsByWorkspace: map[string]string{"ws-1": "wf-1"}, }, recorder.got) } @@ -602,8 +603,9 @@ func TestHTTPCreateTaskRecordsRepositoryWithoutProfileIDs(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) require.Equal(t, 1, recorder.calls) assert.Equal(t, usermodels.TaskCreateLastUsed{ - RepositoryID: "repo-2", - Branch: "main", + RepositoryID: "repo-2", + Branch: "main", + WorkflowIDsByWorkspace: map[string]string{"ws-1": "wf-1"}, }, recorder.got) } @@ -645,6 +647,21 @@ func TestBuildTaskCreateLastUsedPatchRecordsFirstWorkspaceRepository(t *testing. }, patch) } +func TestBuildTaskCreateLastUsedPatchRecordsWorkspaceWorkflow(t *testing.T) { + patch := buildTaskCreateLastUsedPatch(httpCreateTaskRequest{ + WorkspaceID: "workspace-1", + WorkflowID: "workflow-1", + AgentProfileID: "agent-2", + ExecutorProfileID: "exec-profile-2", + }, nil) + + assert.Equal(t, usermodels.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{"workspace-1": "workflow-1"}, + AgentProfileID: "agent-2", + ExecutorProfileID: "exec-profile-2", + }, patch) +} + func TestBuildTaskCreateLastUsedPatchUsesFreshBranchRequestBase(t *testing.T) { patch := buildTaskCreateLastUsedPatch(httpCreateTaskRequest{ Repositories: []httpTaskRepositoryInput{{ @@ -725,10 +742,11 @@ func TestWSCreateTaskRecordsFinalLastUsedSelections(t *testing.T) { require.Equal(t, ws.MessageTypeResponse, resp.Type) require.Equal(t, 1, recorder.calls) assert.Equal(t, usermodels.TaskCreateLastUsed{ - RepositoryID: "repo-2", - Branch: "feature/current", - AgentProfileID: "agent-2", - ExecutorProfileID: "exec-profile-2", + RepositoryID: "repo-2", + Branch: "feature/current", + AgentProfileID: "agent-2", + ExecutorProfileID: "exec-profile-2", + WorkflowIDsByWorkspace: map[string]string{"ws-1": "wf-1"}, }, recorder.got) } @@ -797,8 +815,9 @@ func TestWSCreateTaskRecordsFreshBranchRequestBase(t *testing.T) { require.Equal(t, ws.MessageTypeResponse, resp.Type) require.Equal(t, 1, recorder.calls) assert.Equal(t, usermodels.TaskCreateLastUsed{ - RepositoryID: "repo-2", - Branch: "main", + RepositoryID: "repo-2", + Branch: "main", + WorkflowIDsByWorkspace: map[string]string{"ws-1": "wf-1"}, }, recorder.got) } diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 98c6ee86a1..dfa6337310 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -213,6 +213,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M response.AgentExecutionID = launchResp.AgentExecutionID } h.recordTaskCreateLastUsed(ctx, httpCreateTaskRequest{ + WorkspaceID: req.WorkspaceID, + WorkflowID: req.WorkflowID, AgentProfileID: req.AgentProfileID, ExecutorProfileID: req.ExecutorProfileID, Repositories: req.Repositories, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 3721912876..1597306dd0 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -202,8 +202,9 @@ type SidebarTaskPrefs struct { } type TaskCreateLastUsed struct { - RepositoryID string `json:"repository_id"` - Branch string `json:"branch"` - AgentProfileID string `json:"agent_profile_id"` - ExecutorProfileID string `json:"executor_profile_id"` + RepositoryID string `json:"repository_id"` + Branch string `json:"branch"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + WorkflowIDsByWorkspace map[string]string `json:"workflow_ids_by_workspace"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 573b0868ff..f7599dee3b 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -197,7 +197,8 @@ func taskCreateLastUsedPatchEmpty(patch models.TaskCreateLastUsed) bool { return patch.RepositoryID == "" && patch.Branch == "" && patch.AgentProfileID == "" && - patch.ExecutorProfileID == "" + patch.ExecutorProfileID == "" && + len(patch.WorkflowIDsByWorkspace) == 0 } // applyBasicSettings copies simple (non-validated) fields from req to settings. diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 2f4f2c63fd..54f86f1d2a 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "slices" "strings" "testing" @@ -1098,7 +1099,7 @@ func TestUpdateUserSettingsCombinesSettingsAndTaskCreatePatch(t *testing.T) { if repo.updateCalls != 0 { t.Fatalf("expected task-create patch to be folded into settings write, got %d separate update calls", repo.updateCalls) } - if repo.preservingPatch == nil || *repo.preservingPatch != patch { + if repo.preservingPatch == nil || !reflect.DeepEqual(*repo.preservingPatch, patch) { t.Fatalf("expected preserving write patch %+v, got %+v", patch, repo.preservingPatch) } if len(eventBus.publishedEvents) != 1 { @@ -1300,7 +1301,7 @@ func TestClearDefaultEditorIDPreservesTaskCreateLastUsed(t *testing.T) { if !ok { t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data) } - if data["task_create_last_used"] != updatedSettings.TaskCreateLastUsed { + if !reflect.DeepEqual(data["task_create_last_used"], updatedSettings.TaskCreateLastUsed) { t.Fatalf("expected event to include preserved task-create state %+v, got %+v", updatedSettings.TaskCreateLastUsed, data["task_create_last_used"]) } } @@ -1358,7 +1359,7 @@ func TestRecordTaskCreateLastUsed(t *testing.T) { if repo.updateUserID != store.DefaultUserID { t.Fatalf("expected update user id %q, got %q", store.DefaultUserID, repo.updateUserID) } - if repo.updatePatch != patch { + if !reflect.DeepEqual(repo.updatePatch, patch) { t.Fatalf("expected patch %+v, got %+v", patch, repo.updatePatch) } if len(eventBus.publishedEvents) != 1 { @@ -1375,11 +1376,36 @@ func TestRecordTaskCreateLastUsed(t *testing.T) { if !ok { t.Fatalf("expected event data map, got %T", published.Data) } - if data["task_create_last_used"] != updatedSettings.TaskCreateLastUsed { + if !reflect.DeepEqual(data["task_create_last_used"], updatedSettings.TaskCreateLastUsed) { t.Fatalf("expected event task-create state %+v, got %+v", updatedSettings.TaskCreateLastUsed, data["task_create_last_used"]) } }) + t.Run("workflow-only patch updates repo and publishes settings event", func(t *testing.T) { + patch := models.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{"workspace-1": "workflow-1"}, + } + updatedSettings := &models.UserSettings{ + UserID: store.DefaultUserID, + TaskCreateLastUsed: models.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{"workspace-1": "workflow-1"}, + }, + } + repo := &recordingUserRepository{updateSettings: updatedSettings} + eventBus := &recordingEventBus{} + svc := newTestService(repo, eventBus) + + if err := svc.RecordTaskCreateLastUsed(context.Background(), patch); err != nil { + t.Fatalf("RecordTaskCreateLastUsed: %v", err) + } + if repo.updateCalls != 1 || !reflect.DeepEqual(repo.updatePatch, patch) { + t.Fatalf("expected workflow-only patch to update repo once: calls=%d patch=%+v", repo.updateCalls, repo.updatePatch) + } + if len(eventBus.publishedEvents) != 1 { + t.Fatalf("expected one settings event, got %d", len(eventBus.publishedEvents)) + } + }) + t.Run("repo error is propagated without publishing", func(t *testing.T) { repoErr := errors.New("update failed") repo := &recordingUserRepository{updateErr: repoErr} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index b684b4f138..7d5747190d 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "time" @@ -367,12 +368,50 @@ func makeTaskCreateLastUsedJSONSetArgs(patch models.TaskCreateLastUsed) []any { if patch.ExecutorProfileID != "" { args = append(args, "$.task_create_last_used.executor_profile_id", patch.ExecutorProfileID) } + workflowIDs := make([]string, 0, len(patch.WorkflowIDsByWorkspace)) + for workspaceID := range patch.WorkflowIDsByWorkspace { + workflowIDs = append(workflowIDs, workspaceID) + } + sort.Strings(workflowIDs) + for _, workspaceID := range workflowIDs { + workflowID := patch.WorkflowIDsByWorkspace[workspaceID] + if !isSafeTaskCreateWorkspacePathKey(workspaceID) || workflowID == "" { + continue + } + // Workspace IDs are generated UUIDs. SQLite JSON1 does not support + // PostgreSQL-style parameterized path segments, so reject punctuation + // rather than interpolating a key that could change the JSON path. + args = append(args, + "$.task_create_last_used.workflow_ids_by_workspace."+workspaceID, + workflowID, + ) + } return args } +func isSafeTaskCreateWorkspacePathKey(value string) bool { + if value == "" { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '-' || char == '_' { + continue + } + return false + } + return true +} + func buildPostgresTaskCreateLastUsedUpdate(patch models.TaskCreateLastUsed) (string, []any) { base := "(CASE WHEN settings IS NULL OR settings = 'null' OR settings = '' THEN '{}'::jsonb ELSE settings::jsonb END)" - expr := fmt.Sprintf("jsonb_set(%s, '{task_create_last_used}', COALESCE(%s->'task_create_last_used', '{}'::jsonb), true)", base, base) + expr := fmt.Sprintf( + "jsonb_set(%s, '{task_create_last_used}', %s, true)", + base, + normalizePostgresTaskCreateLastUsedExpr(base), + ) args := []any{} expr, args = applyPostgresTaskCreateLastUsedPatch(expr, patch, args) query := fmt.Sprintf(` @@ -385,7 +424,10 @@ func buildPostgresTaskCreateLastUsedUpdate(patch models.TaskCreateLastUsed) (str func buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch *models.TaskCreateLastUsed) (string, []any) { base := "(CASE WHEN settings IS NULL OR settings = 'null' OR settings = '' THEN '{}'::jsonb ELSE settings::jsonb END)" - expr := fmt.Sprintf("jsonb_set(?::jsonb, '{task_create_last_used}', COALESCE(%s->'task_create_last_used', '{}'::jsonb), true)", base) + expr := fmt.Sprintf( + "jsonb_set(?::jsonb, '{task_create_last_used}', %s, true)", + normalizePostgresTaskCreateLastUsedExpr(base), + ) args := []any{} if patch != nil { expr, args = applyPostgresTaskCreateLastUsedPatch(expr, *patch, args) @@ -398,6 +440,20 @@ func buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch *models.T return query, args } +func normalizePostgresTaskCreateLastUsedExpr(base string) string { + taskCreate := fmt.Sprintf("COALESCE(%s->'task_create_last_used', '{}'::jsonb)", base) + workflowMap := fmt.Sprintf( + "CASE WHEN jsonb_typeof(%s->'workflow_ids_by_workspace') = 'object' THEN %s->'workflow_ids_by_workspace' ELSE '{}'::jsonb END", + taskCreate, + taskCreate, + ) + return fmt.Sprintf( + "jsonb_set(%s, '{workflow_ids_by_workspace}', %s, true)", + taskCreate, + workflowMap, + ) +} + func applyPostgresTaskCreateLastUsedPatch(expr string, patch models.TaskCreateLastUsed, args []any) (string, []any) { if patch.RepositoryID != "" { expr = fmt.Sprintf("jsonb_set(%s, '{task_create_last_used,repository_id}', to_jsonb(?::text), true)", expr) @@ -415,6 +471,22 @@ func applyPostgresTaskCreateLastUsedPatch(expr string, patch models.TaskCreateLa expr = fmt.Sprintf("jsonb_set(%s, '{task_create_last_used,executor_profile_id}', to_jsonb(?::text), true)", expr) args = append(args, patch.ExecutorProfileID) } + workflowIDs := make([]string, 0, len(patch.WorkflowIDsByWorkspace)) + for workspaceID := range patch.WorkflowIDsByWorkspace { + workflowIDs = append(workflowIDs, workspaceID) + } + sort.Strings(workflowIDs) + for _, workspaceID := range workflowIDs { + workflowID := patch.WorkflowIDsByWorkspace[workspaceID] + if workspaceID == "" || workflowID == "" { + continue + } + expr = fmt.Sprintf( + "jsonb_set(%s, ARRAY['task_create_last_used','workflow_ids_by_workspace',?::text], to_jsonb(?::text), true)", + expr, + ) + args = append(args, workspaceID, workflowID) + } return expr, args } diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 52dbafa515..5dd8ec310c 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -22,7 +22,7 @@ type settingsScanner struct { func upsertUserSettingsForTest(t *testing.T, repo *sqliteRepository, ctx context.Context, settings *models.UserSettings) { t.Helper() var patch *models.TaskCreateLastUsed - if settings.TaskCreateLastUsed != (models.TaskCreateLastUsed{}) { + if !reflect.DeepEqual(settings.TaskCreateLastUsed, models.TaskCreateLastUsed{}) { patch = &settings.TaskCreateLastUsed } if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, patch); err != nil { @@ -737,6 +737,52 @@ func TestSQLiteRepositoryUpdateTaskCreateLastUsedPatchesNonEmptyFields(t *testin } } +func TestSQLiteRepositoryUpdateTaskCreateLastUsedPreservesWorkflowHistory(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) + } + settings.TaskCreateLastUsed = models.TaskCreateLastUsed{ + RepositoryID: "repo-1", + WorkflowIDsByWorkspace: map[string]string{ + "workspace-1": "workflow-1", + "workspace-2": "workflow-2", + }, + } + upsertUserSettingsForTest(t, repo, ctx, settings) + + got, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{"workspace-3": "workflow-3"}, + }) + if err != nil { + t.Fatalf("update task-create workflow history: %v", err) + } + + if got.TaskCreateLastUsed.RepositoryID != "repo-1" { + t.Fatalf("repository id should be preserved, got %q", got.TaskCreateLastUsed.RepositoryID) + } + want := map[string]string{ + "workspace-1": "workflow-1", + "workspace-2": "workflow-2", + "workspace-3": "workflow-3", + } + if !reflect.DeepEqual(got.TaskCreateLastUsed.WorkflowIDsByWorkspace, want) { + t.Fatalf("workflow history = %#v, want %#v", got.TaskCreateLastUsed.WorkflowIDsByWorkspace, want) + } +} + func TestSQLiteRepositoryUpdateTaskCreateLastUsedClearsBranchOnRepositoryChange(t *testing.T) { conn, err := sqlx.Open("sqlite3", ":memory:") if err != nil { @@ -808,6 +854,46 @@ func TestBuildPostgresTaskCreateLastUsedUpdatePatchesNonEmptyFields(t *testing.T } } +func TestBuildPostgresTaskCreateLastUsedUpdatePatchesWorkflowHistoryEntries(t *testing.T) { + query, args := buildPostgresTaskCreateLastUsedUpdate(models.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{ + "workspace-1": "workflow-1", + "workspace-2": "workflow-2", + }, + }) + + for _, workspaceID := range []string{"workspace-1", "workspace-2"} { + path := "ARRAY['task_create_last_used','workflow_ids_by_workspace',?::text]" + if !strings.Contains(query, path) { + t.Fatalf("postgres update should patch workflow path %q for %s: %s", path, workspaceID, query) + } + } + if !strings.Contains(query, "{workflow_ids_by_workspace}") { + t.Fatalf("postgres update should initialize the workflow history object: %s", query) + } + if len(args) != 4 { + t.Fatalf("expected workspace and workflow args per entry, got %d", len(args)) + } +} + +func TestMakeTaskCreateLastUsedJSONSetArgsRejectsUnsafeWorkspacePathKeys(t *testing.T) { + args := makeTaskCreateLastUsedJSONSetArgs(models.TaskCreateLastUsed{ + WorkflowIDsByWorkspace: map[string]string{ + "workspace-safe": "workflow-safe", + "workspace.with.dot": "workflow-dot", + "workspace[bracket]": "workflow-bracket", + }, + }) + + if len(args) != 2 { + t.Fatalf("expected only the safe workspace entry, got %#v", args) + } + if args[0] != "$.task_create_last_used.workflow_ids_by_workspace.workspace-safe" || + args[1] != "workflow-safe" { + t.Fatalf("unexpected safe workspace args: %#v", args) + } +} + func TestBuildPostgresTaskCreateLastUsedUpdateClearsBranchOnRepositoryChange(t *testing.T) { query, args := buildPostgresTaskCreateLastUsedUpdate(models.TaskCreateLastUsed{ RepositoryID: "repo-after", diff --git a/apps/web/components/state-provider.test.tsx b/apps/web/components/state-provider.test.tsx index ae3666e05d..f53b510f3e 100644 --- a/apps/web/components/state-provider.test.tsx +++ b/apps/web/components/state-provider.test.tsx @@ -82,6 +82,33 @@ describe("StateProvider task-create queued overlay", () => { branch: "main", agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, + }, + }, + }} + > +
ready
+ , + ); + + expect(readQueuedTaskCreateLastUsedState()).toEqual({}); + }); + + it("compares workflow history by content when clearing the queued overlay", () => { + syncTaskCreateLastUsed({ workspace_id: "workspace-1", workflow_id: "workflow-1" }); + + render( + | undefined, + b: Record | undefined, +) { + const left = a ?? {}; + const right = b ?? {}; + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) return false; + return keys.every((key) => left[key] === right[key]); +} + export function useAppStore(selector: (state: AppState) => T) { const store = useContext(StoreContext); if (!store) { diff --git a/apps/web/components/task-create-dialog-autopick.ts b/apps/web/components/task-create-dialog-autopick.ts index b08c9a011e..f49100a8e6 100644 --- a/apps/web/components/task-create-dialog-autopick.ts +++ b/apps/web/components/task-create-dialog-autopick.ts @@ -141,9 +141,10 @@ export function useWorkflowAgentProfileEffect( lastUsedAgentProfileId?: string | null; authLoaded?: boolean; userSettingsLoaded?: boolean; + effectiveWorkflowId?: string | null; } = {}, ) { - const { lastUsedAgentProfileId, authLoaded = true } = options; + const { lastUsedAgentProfileId, authLoaded = true, effectiveWorkflowId } = options; const { agentProfileId, workflowAgentProfileId, @@ -153,12 +154,13 @@ export function useWorkflowAgentProfileEffect( setWorkflowAgentProfileId, } = fs; useEffect(() => { - if (!selectedWorkflowId) { + const resolvedWorkflowId = effectiveWorkflowId ?? selectedWorkflowId; + if (!resolvedWorkflowId) { setWorkflowAgentProfileId(""); workflowAutopickDebug("no-workflow", { cleared: "workflowAgentProfileId" }); return; } - const workflow = workflows.find((w) => w.id === selectedWorkflowId); + const workflow = workflows.find((w) => w.id === resolvedWorkflowId); if (workflow?.agent_profile_id) { // Always lock the selector when the workflow specifies an agent profile. // This prevents the race condition where agentProfiles hasn't loaded yet. @@ -168,12 +170,12 @@ export function useWorkflowAgentProfileEffect( if (profileExists) { setAgentProfileId(workflow.agent_profile_id); workflowAutopickDebug("locked", { - workflow: selectedWorkflowId, + workflow: resolvedWorkflowId, set_to: workflow.agent_profile_id, }); } else { workflowAutopickDebug("locked-missing", { - workflow: selectedWorkflowId, + workflow: resolvedWorkflowId, missing: workflow.agent_profile_id, }); } @@ -226,6 +228,7 @@ export function useWorkflowAgentProfileEffect( } }, [ selectedWorkflowId, + effectiveWorkflowId, agentProfileId, workflowAgentProfileId, executorProfileId, @@ -258,8 +261,9 @@ export function useAgentProfileAutopickEffect( // Check synchronously whether the selected workflow has an agent override. // This avoids a race condition where workflowAgentProfileId state hasn't // been committed yet by the workflow effect running in the same cycle. - const workflowHasAgent = selectedWorkflowId - ? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id) + const resolvedWorkflowId = sel.effectiveWorkflowId ?? selectedWorkflowId; + const workflowHasAgent = resolvedWorkflowId + ? workflows.some((w) => w.id === resolvedWorkflowId && w.agent_profile_id) : false; const lastAgentProfileId = resolveLastUsedAgentProfileId( compatibleAgentProfiles, @@ -286,7 +290,7 @@ export function useAgentProfileAutopickEffect( buildAgentAutopickDebugFields({ decision, agentProfileId, - selectedWorkflowId, + selectedWorkflowId: resolvedWorkflowId, executorProfileId, agentProfiles, compatibleAgentProfiles, @@ -306,6 +310,7 @@ export function useAgentProfileAutopickEffect( agentProfileId, workflowAgentProfileId, selectedWorkflowId, + sel.effectiveWorkflowId, workflows, agentProfiles, compatibleAgentProfiles, diff --git a/apps/web/components/task-create-dialog-computed.ts b/apps/web/components/task-create-dialog-computed.ts index 6aa8ab0310..e68483e156 100644 --- a/apps/web/components/task-create-dialog-computed.ts +++ b/apps/web/components/task-create-dialog-computed.ts @@ -18,7 +18,7 @@ import { import { computePassthroughProfile } from "@/components/task-create-dialog-helpers"; import { computeDialogDefaultStepId, - computeSingleWorkflowFallbackId, + resolveEffectiveTaskCreateWorkflowId, } from "@/components/task-create-dialog-defaults"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; @@ -56,6 +56,40 @@ function pickExecutorDisabledReason( return undefined; } +function resolveDialogWorkflowSelection({ + workspaceId, + workflowId, + selectedWorkflowId, + lockedWorkflow, + lastUsedWorkflowIdsByWorkspace, + userSettingsLoaded, + workflows, +}: Pick< + DialogComputedArgs, + | "workspaceId" + | "workflowId" + | "lockedWorkflow" + | "lastUsedWorkflowIdsByWorkspace" + | "userSettingsLoaded" + | "workflows" +> & { selectedWorkflowId: string | null }) { + const effectiveWorkflowId = resolveEffectiveTaskCreateWorkflowId({ + workspaceId, + lockedWorkflowId: lockedWorkflow ? workflowId : null, + manualWorkflowId: selectedWorkflowId, + lastUsedWorkflowId: + userSettingsLoaded === false + ? null + : (lastUsedWorkflowIdsByWorkspace[workspaceId ?? ""] ?? null), + contextWorkflowId: lockedWorkflow ? null : workflowId, + workflows, + }); + const workflowAgentProfileId = effectiveWorkflowId + ? (workflows.find((workflow) => workflow.id === effectiveWorkflowId)?.agent_profile_id ?? "") + : ""; + return { effectiveWorkflowId, workflowAgentProfileId }; +} + /** * The form has a repo selection when: * - noRepository is on (intentionally repo-less), OR @@ -160,20 +194,19 @@ export function useDialogComputed({ repositories, workflows, snapshots, + lockedWorkflow, + lastUsedWorkflowIdsByWorkspace, + userSettingsLoaded, }: DialogComputedArgs): DialogComputedValues { - const singleWorkflowId = computeSingleWorkflowFallbackId( - fs.selectedWorkflowId, + const { effectiveWorkflowId, workflowAgentProfileId } = resolveDialogWorkflowSelection({ + selectedWorkflowId: fs.selectedWorkflowId, + workspaceId, workflowId, + lockedWorkflow, + lastUsedWorkflowIdsByWorkspace, + userSettingsLoaded, workflows, - ); - const effectiveWorkflowId = fs.selectedWorkflowId ?? workflowId ?? singleWorkflowId; - // Compute workflow agent lock directly from data — avoids effect timing issues. - const workflowAgentProfileId = (() => { - const wfId = effectiveWorkflowId; - if (!wfId) return ""; - const wf = workflows.find((w) => w.id === wfId); - return wf?.agent_profile_id ?? ""; - })(); + }); const workflowAgentLocked = Boolean(workflowAgentProfileId); // fs.agentProfileId lags behind the workflow override on dialog re-open // (effect deps don't change), so fall back to the synchronous value. diff --git a/apps/web/components/task-create-dialog-defaults.test.ts b/apps/web/components/task-create-dialog-defaults.test.ts index 4d569ae842..430d375fbf 100644 --- a/apps/web/components/task-create-dialog-defaults.test.ts +++ b/apps/web/components/task-create-dialog-defaults.test.ts @@ -5,6 +5,11 @@ const { computeDialogDefaultStepId, computeSingleWorkflowFallbackId } = taskCrea const HIDDEN_WORKFLOW_ID = "improve-kandev"; const VISIBLE_WORKFLOW_ID = "visible"; const VISIBLE_START_STEP_ID = "visible-start"; +const DEV_WORKFLOW_ID = "dev"; +const REVIEW_WORKFLOW_ID = "review"; +const SUPPORT_WORKFLOW_ID = "support"; +const WORKSPACE_ONE = "workspace-1"; +const WORKSPACE_TWO = "workspace-2"; type WorkflowContextResolver = ( workflowId: string | null, @@ -24,6 +29,109 @@ describe("computeSingleWorkflowFallbackId", () => { }); }); +const WORKFLOW_RESOLUTION_WORKFLOWS = [ + { id: DEV_WORKFLOW_ID, workspaceId: WORKSPACE_ONE }, + { id: REVIEW_WORKFLOW_ID, workspaceId: WORKSPACE_ONE }, + { id: SUPPORT_WORKFLOW_ID, workspaceId: WORKSPACE_TWO }, + { id: "hidden", workspaceId: WORKSPACE_ONE, hidden: true }, +]; + +const WORKFLOW_RESOLUTION_CASES = [ + { + name: "prefers a locked workflow", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: REVIEW_WORKFLOW_ID, + manualWorkflowId: null, + lastUsedWorkflowId: DEV_WORKFLOW_ID, + contextWorkflowId: DEV_WORKFLOW_ID, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: REVIEW_WORKFLOW_ID, + }, + { + name: "prefers a workflow manually selected in the open dialog", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: null, + manualWorkflowId: REVIEW_WORKFLOW_ID, + lastUsedWorkflowId: DEV_WORKFLOW_ID, + contextWorkflowId: DEV_WORKFLOW_ID, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: REVIEW_WORKFLOW_ID, + }, + { + name: "prefers workspace last-used over an unlocked conflicting filter", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: null, + manualWorkflowId: null, + lastUsedWorkflowId: DEV_WORKFLOW_ID, + contextWorkflowId: REVIEW_WORKFLOW_ID, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: DEV_WORKFLOW_ID, + }, + { + name: "falls back when remembered workflow is hidden", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: null, + manualWorkflowId: null, + lastUsedWorkflowId: "hidden", + contextWorkflowId: REVIEW_WORKFLOW_ID, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: REVIEW_WORKFLOW_ID, + }, + { + name: "falls back when remembered workflow belongs to another workspace", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: null, + manualWorkflowId: null, + lastUsedWorkflowId: SUPPORT_WORKFLOW_ID, + contextWorkflowId: REVIEW_WORKFLOW_ID, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: REVIEW_WORKFLOW_ID, + }, + { + name: "selects the sole visible workflow without context", + args: { + workspaceId: WORKSPACE_TWO, + lockedWorkflowId: null, + manualWorkflowId: null, + lastUsedWorkflowId: null, + contextWorkflowId: null, + workflows: [ + { id: SUPPORT_WORKFLOW_ID, workspaceId: WORKSPACE_TWO }, + { id: "hidden", workspaceId: WORKSPACE_TWO, hidden: true }, + ], + }, + expected: SUPPORT_WORKFLOW_ID, + }, + { + name: "leaves multiple workflows unselected without a valid default", + args: { + workspaceId: WORKSPACE_ONE, + lockedWorkflowId: null, + manualWorkflowId: null, + lastUsedWorkflowId: null, + contextWorkflowId: null, + workflows: WORKFLOW_RESOLUTION_WORKFLOWS, + }, + expected: null, + }, +]; + +describe("resolveEffectiveTaskCreateWorkflowId", () => { + it.each(WORKFLOW_RESOLUTION_CASES)("$name", ({ args, expected }) => { + expect(taskCreateDefaults.resolveEffectiveTaskCreateWorkflowId(args)).toBe(expected); + }); +}); + describe("resolveTaskCreateWorkflowContext", () => { it.each([ { diff --git a/apps/web/components/task-create-dialog-defaults.ts b/apps/web/components/task-create-dialog-defaults.ts index 57eba502d2..1ba735283b 100644 --- a/apps/web/components/task-create-dialog-defaults.ts +++ b/apps/web/components/task-create-dialog-defaults.ts @@ -1,6 +1,49 @@ import type { DialogComputedArgs, StepType } from "@/components/task-create-dialog-types"; import { computeEffectiveStepId } from "@/components/task-create-dialog-helpers"; +type WorkflowCandidate = { + id: string; + workspaceId?: string; + hidden?: boolean; +}; + +export type ResolveEffectiveTaskCreateWorkflowIdArgs = { + workspaceId: string | null; + lockedWorkflowId: string | null; + manualWorkflowId: string | null; + lastUsedWorkflowId: string | null; + contextWorkflowId: string | null; + workflows: WorkflowCandidate[]; +}; + +function isVisibleWorkflow(workflow: WorkflowCandidate | undefined, workspaceId: string | null) { + return Boolean( + workflow && + !workflow.hidden && + (!workspaceId || !workflow.workspaceId || workflow.workspaceId === workspaceId), + ); +} + +export function resolveEffectiveTaskCreateWorkflowId({ + workspaceId, + lockedWorkflowId, + manualWorkflowId, + lastUsedWorkflowId, + contextWorkflowId, + workflows, +}: ResolveEffectiveTaskCreateWorkflowIdArgs): string | null { + if (lockedWorkflowId) return lockedWorkflowId; + + const visibleWorkflows = workflows.filter((workflow) => isVisibleWorkflow(workflow, workspaceId)); + const visibleWorkflowIDs = new Set(visibleWorkflows.map((workflow) => workflow.id)); + if (manualWorkflowId && visibleWorkflowIDs.has(manualWorkflowId)) return manualWorkflowId; + if (lastUsedWorkflowId && visibleWorkflowIDs.has(lastUsedWorkflowId)) { + return lastUsedWorkflowId; + } + if (contextWorkflowId && visibleWorkflowIDs.has(contextWorkflowId)) return contextWorkflowId; + return visibleWorkflows.length === 1 ? (visibleWorkflows[0]?.id ?? null) : null; +} + export function resolveTaskCreateWorkflowContext( workflowId: string | null, defaultStepId: string | null, diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 1eaa48c52f..cd0f4ebc0a 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -578,6 +578,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate lastUsedAgentProfileId: args.lastUsedAgentProfileId, authLoaded, userSettingsLoaded: args.userSettingsLoaded, + effectiveWorkflowId, }); useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories, { lastUsedRepositoryId: args.lastUsedRepositoryId, @@ -598,6 +599,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate userSettingsLoaded: args.userSettingsLoaded, lastUsedAgentProfileId: args.lastUsedAgentProfileId, lastUsedExecutorProfileId: args.lastUsedExecutorProfileId, + effectiveWorkflowId, }, workflows, ); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 3cd8f80dff..131fd28945 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useState } from "react"; +import { memo } from "react"; import Link from "@/components/routing/app-link"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; @@ -293,20 +293,10 @@ export const WorkflowSection = memo(function WorkflowSection({ agentProfiles, workflowLocked, }: WorkflowSectionProps) { - const [lastUsedWorkflowId, setLastUsedWorkflowId] = useState(null); - // Hidden workflows (e.g. improve-kandev) are excluded from the picker; they // remain reachable via their dedicated entry point. const workflows = allWorkflows.filter((w) => !w.hidden); - const handleWorkflowChange = useCallback( - (workflowId: string) => { - setLastUsedWorkflowId(workflowId); - onWorkflowChange(workflowId); - }, - [onWorkflowChange], - ); - if (!isCreateMode || isTaskStarted) return null; if (workflowLocked) return null; @@ -316,8 +306,7 @@ export const WorkflowSection = memo(function WorkflowSection({ workflows={workflows} snapshots={snapshots} selectedWorkflowId={effectiveWorkflowId ?? null} - onWorkflowChange={handleWorkflowChange} - lastUsedWorkflowId={lastUsedWorkflowId} + onWorkflowChange={onWorkflowChange} agentProfiles={agentProfiles} /> ); diff --git a/apps/web/components/task-create-dialog-handlers.test.ts b/apps/web/components/task-create-dialog-handlers.test.ts index 229832ffe6..a7c8513acf 100644 --- a/apps/web/components/task-create-dialog-handlers.test.ts +++ b/apps/web/components/task-create-dialog-handlers.test.ts @@ -14,6 +14,15 @@ import { const WORKTREE_PROFILE_ID = "worktree-profile"; const LOCAL_PROFILE_ID = "local-profile"; +const WORKSPACE_ONE = "workspace-1"; +const WORKSPACE_TWO = "workspace-2"; +const WORKFLOW_ONE = "workflow-1"; +const WORKFLOW_TWO = "workflow-2"; + +function resetQueuedTaskCreateLastUsedForTest() { + window.localStorage.clear(); + resetTaskCreateLastUsedSync({ clearQueued: true }); +} function executor( id: string, @@ -248,6 +257,7 @@ describe("syncTaskCreateLastUsed", () => { branch: "main", agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, }, }); @@ -265,6 +275,7 @@ describe("syncTaskCreateLastUsed", () => { branch: "feature", agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, }, }); @@ -273,10 +284,7 @@ describe("syncTaskCreateLastUsed", () => { }); describe("queueTaskCreateLastUsedFromPayload", () => { - beforeEach(() => { - window.localStorage.clear(); - resetTaskCreateLastUsedSync({ clearQueued: true }); - }); + beforeEach(resetQueuedTaskCreateLastUsedForTest); it("leaves the queued overlay unchanged for null or undefined payloads", () => { syncTaskCreateLastUsed({ branch: "feature" }); @@ -299,6 +307,10 @@ describe("queueTaskCreateLastUsedFromPayload", () => { executorProfileId: "exec-1", }); }); +}); + +describe("queueTaskCreateLastUsedFromPayload repositories", () => { + beforeEach(resetQueuedTaskCreateLastUsedForTest); it("uses the first workspace repository and skips rows without repository ids", () => { queueTaskCreateLastUsedFromPayload({ @@ -383,3 +395,74 @@ describe("queueTaskCreateLastUsedFromPayload", () => { }); }); }); + +describe("queueTaskCreateLastUsedFromPayload workflow history", () => { + beforeEach(resetQueuedTaskCreateLastUsedForTest); + + it("queues the successful workflow under its workspace", () => { + queueTaskCreateLastUsedFromPayload({ + workspace_id: WORKSPACE_ONE, + workflow_id: WORKFLOW_ONE, + repositories: [], + }); + + expect(readQueuedTaskCreateLastUsedState()).toEqual({ + workflowIdsByWorkspace: { [WORKSPACE_ONE]: WORKFLOW_ONE }, + }); + }); + + it("merges workflow entries from consecutive workspace submissions", () => { + queueTaskCreateLastUsedFromPayload({ + workspace_id: WORKSPACE_ONE, + workflow_id: WORKFLOW_ONE, + repositories: [], + }); + queueTaskCreateLastUsedFromPayload({ + workspace_id: WORKSPACE_TWO, + workflow_id: WORKFLOW_TWO, + repositories: [], + }); + + expect(readQueuedTaskCreateLastUsedState()).toEqual({ + workflowIdsByWorkspace: { + [WORKSPACE_ONE]: WORKFLOW_ONE, + [WORKSPACE_TWO]: WORKFLOW_TWO, + }, + }); + }); + + it("waits for every queued workspace workflow to appear in settings", () => { + syncTaskCreateLastUsed({ workspace_id: WORKSPACE_ONE, workflow_id: WORKFLOW_ONE }); + syncTaskCreateLastUsed({ workspace_id: WORKSPACE_TWO, workflow_id: WORKFLOW_TWO }); + + resetTaskCreateLastUsedSync({ + syncedSettings: { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + workflowIdsByWorkspace: { [WORKSPACE_ONE]: WORKFLOW_ONE }, + }, + }); + expect(readQueuedTaskCreateLastUsedState()).toEqual({ + workflowIdsByWorkspace: { + [WORKSPACE_ONE]: WORKFLOW_ONE, + [WORKSPACE_TWO]: WORKFLOW_TWO, + }, + }); + + resetTaskCreateLastUsedSync({ + syncedSettings: { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + workflowIdsByWorkspace: { + [WORKSPACE_ONE]: WORKFLOW_ONE, + [WORKSPACE_TWO]: WORKFLOW_TWO, + }, + }, + }); + expect(readQueuedTaskCreateLastUsedState()).toEqual({}); + }); +}); diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index 9416943ddc..63630d275a 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -12,9 +12,13 @@ type TaskCreateLastUsedPatch = { branch?: string | null; agent_profile_id?: string | null; executor_profile_id?: string | null; + workspace_id?: string | null; + workflow_id?: string | null; }; type TaskCreateLastUsedPayload = { + workspace_id?: string; + workflow_id?: string; repositories?: Array<{ repository_id?: string; base_branch?: string; @@ -136,6 +140,13 @@ function taskCreateLastUsedSettingsMatchQueue( ) { return Object.entries(lastQueuedLastUsed).every(([key, value]) => { if (value === undefined) return true; + if (key === "workflowIdsByWorkspace") { + const queued = value as Record; + const synced = settings?.workflowIdsByWorkspace ?? {}; + return Object.entries(queued).every(([workspaceId, workflowId]) => { + return synced[workspaceId] === workflowId; + }); + } return settings?.[key as keyof TaskCreateLastUsedState] === value; }); } @@ -148,6 +159,10 @@ function mapTaskCreateLastUsedPatch( branch: pending.branch, agentProfileId: pending.agent_profile_id, executorProfileId: pending.executor_profile_id, + workflowIdsByWorkspace: + pending.workspace_id && pending.workflow_id + ? { [pending.workspace_id]: pending.workflow_id } + : undefined, }; } @@ -158,10 +173,8 @@ function compactTaskCreateLastUsedState(state: Partial) } export function syncTaskCreateLastUsed(patch: TaskCreateLastUsedPatch) { - lastQueuedLastUsed = { - ...lastQueuedLastUsed, - ...compactTaskCreateLastUsedState(mapTaskCreateLastUsedPatch(patch)), - }; + const mapped = compactTaskCreateLastUsedState(mapTaskCreateLastUsedPatch(patch)); + lastQueuedLastUsed = mergeTaskCreateLastUsedState(lastQueuedLastUsed, mapped); lastUsedDebug("overlay-updated", { patch, queued: lastQueuedLastUsed }); } @@ -174,13 +187,36 @@ export function queueTaskCreateLastUsedFromPayload( payload: TaskCreateLastUsedPayload | null | undefined, ) { if (!payload) return; + const previousWorkflowIdsByWorkspace = lastQueuedLastUsed.workflowIdsByWorkspace; + lastQueuedLastUsed = {}; const firstWorkspaceRepo = payload.repositories?.find((repo) => repo.repository_id); - replaceQueuedTaskCreateLastUsed({ + syncTaskCreateLastUsed({ + workspace_id: payload.workspace_id, + workflow_id: payload.workflow_id, repository_id: firstWorkspaceRepo?.repository_id, branch: firstWorkspaceRepo ? taskCreateLastUsedPayloadBranch(firstWorkspaceRepo) : undefined, agent_profile_id: payload.agent_profile_id, executor_profile_id: payload.executor_profile_id, }); + if (previousWorkflowIdsByWorkspace) { + lastQueuedLastUsed = mergeTaskCreateLastUsedState(lastQueuedLastUsed, { + workflowIdsByWorkspace: previousWorkflowIdsByWorkspace, + }); + } +} + +function mergeTaskCreateLastUsedState( + previous: Partial, + patch: Partial, +): Partial { + const merged = { ...previous, ...patch }; + if (patch.workflowIdsByWorkspace) { + merged.workflowIdsByWorkspace = { + ...(previous.workflowIdsByWorkspace ?? {}), + ...patch.workflowIdsByWorkspace, + }; + } + return merged; } function taskCreateLastUsedPayloadBranch( diff --git a/apps/web/components/task-create-dialog-locked-fields.ts b/apps/web/components/task-create-dialog-locked-fields.ts index 737ffd2434..bc9e3fda83 100644 --- a/apps/web/components/task-create-dialog-locked-fields.ts +++ b/apps/web/components/task-create-dialog-locked-fields.ts @@ -22,12 +22,13 @@ export function useLockedFieldSync( workflowId: string | null, initialValues: TaskCreateDialogInitialValues | undefined, fs: LockedFieldFormState, + lockedWorkflow = false, ) { const repoId = initialValues?.repositoryId; const branch = initialValues?.branch; useEffect(() => { if (!open) return; - if (workflowId && workflowId !== fs.selectedWorkflowId) { + if (lockedWorkflow && workflowId && workflowId !== fs.selectedWorkflowId) { fs.setSelectedWorkflowId(workflowId); } if (!repoId) return; @@ -35,5 +36,5 @@ export function useLockedFieldSync( if (current?.repositoryId === repoId && current?.branch === (branch ?? "")) return; fs.setRepositories([{ key: "row-0", repositoryId: repoId, branch: branch ?? "" }]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, workflowId, repoId, branch]); + }, [branch, lockedWorkflow, open, repoId, workflowId]); } diff --git a/apps/web/components/task-create-dialog-setup.ts b/apps/web/components/task-create-dialog-setup.ts index 42c5c982e8..fd7fe5960b 100644 --- a/apps/web/components/task-create-dialog-setup.ts +++ b/apps/web/components/task-create-dialog-setup.ts @@ -195,7 +195,14 @@ function useDialogSetupData( const { open, workspaceId, workflowId, defaultStepId, initialValues } = props; const { toast } = useToast(); const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository); - const data = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs); + const data = useTaskCreateDialogData({ + open, + workspaceId, + workflowId, + defaultStepId, + fs, + lockedWorkflow: props.lockedFields?.workflow === true, + }); const { workflows, agentProfiles, @@ -228,7 +235,7 @@ function useDialogSetupData( lastUsedBranch: taskCreateLastUsed.branch, preserveBranch: initialValues?.checkoutBranch || initialValues?.branch, }); - useLockedFieldSync(open, workflowId, initialValues, fs); + useLockedFieldSync(open, workflowId, initialValues, fs, props.lockedFields?.workflow === true); const handlers = useDialogHandlers(fs, repositories, { workspaceId, executors, @@ -255,7 +262,13 @@ export function useTaskCreateDialogSetup( (state) => state.userSettings.agentGeneratedTaskTitles, ); const autoTitle = mode === "create" && agentGeneratedTaskTitles; - const fs = useDialogFormState(open, workspaceId, workflowId, initialValues); + const fs = useDialogFormState( + open, + workspaceId, + workflowId, + initialValues, + resolvedProps.lockedFields?.workflow === true, + ); const sessionRepoName = useSessionRepoName(isSessionMode); const data = useDialogSetupData(resolvedProps, fs); const { diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index c1bdc4bb6d..510efe62f5 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -69,6 +69,7 @@ type FormResetEffectsArgs = { setCurrentDefaults: (v: { name: string; description: string }) => void; setOpenCycle: React.Dispatch>; prevOpenRef: React.RefObject; + lockedWorkflow: boolean; }; function useFormResetEffects({ @@ -81,6 +82,7 @@ function useFormResetEffects({ setCurrentDefaults, setOpenCycle, prevOpenRef, + lockedWorkflow, }: FormResetEffectsArgs) { // Restore draft or initialValues when dialog opens useEffect(() => { @@ -104,10 +106,16 @@ function useFormResetEffects({ initial_branch: initialValues?.branch ?? initialValues?.checkoutBranch ?? "-", }); setCurrentDefaults(defaults); - resetTaskForm(resetters, defaults.name, defaults.description, workflowId, initialValues); + resetTaskForm( + resetters, + defaults.name, + defaults.description, + lockedWorkflow ? workflowId : null, + initialValues, + ); setDraftDescription(defaults.description); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, workflowId, workspaceId]); + }, [lockedWorkflow, open, workflowId, workspaceId]); useEffect(() => { if (!open) return; @@ -397,6 +405,7 @@ export function useDialogFormState( workspaceId: string | null, workflowId: string | null, initialValues?: TaskCreateDialogInitialValues, + lockedWorkflow = false, ) { const form = useFormStateValues(workflowId); const discovery = useDiscoveryState(); @@ -417,6 +426,7 @@ export function useDialogFormState( setCurrentDefaults: form.setCurrentDefaults, setOpenCycle: form.setOpenCycle, prevOpenRef: form.prevOpenRef, + lockedWorkflow, resetters: { setTaskName: form.setTaskName, setHasTitle: form.setHasTitle, @@ -579,13 +589,23 @@ export function useSessionRepoName(isSessionMode: boolean) { }, [isSessionMode, activeTaskId, kanbanTasks, reposByWorkspace]); } -export function useTaskCreateDialogData( - open: boolean, - workspaceId: string | null, - workflowId: string | null, - defaultStepId: string | null, - fs: DialogFormState, -) { +type TaskCreateDialogDataArgs = { + open: boolean; + workspaceId: string | null; + workflowId: string | null; + defaultStepId: string | null; + fs: DialogFormState; + lockedWorkflow?: boolean; +}; + +export function useTaskCreateDialogData({ + open, + workspaceId, + workflowId, + defaultStepId, + fs, + lockedWorkflow = false, +}: TaskCreateDialogDataArgs) { const workflows = useAppStore((state) => state.workflows.items); const workspaces = useAppStore((state) => state.workspaces.items); const agentProfiles = useAppStore((state) => state.agentProfiles.items); @@ -622,6 +642,10 @@ export function useTaskCreateDialogData( repositories, workflows, snapshots, + lockedWorkflow, + lastUsedWorkflowIdsByWorkspace: + taskCreateUserSettings.userSettings.taskCreateLastUsed.workflowIdsByWorkspace ?? {}, + userSettingsLoaded: taskCreateUserSettings.loaded, }); return { workflows, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 59cb5fd3dc..c961a0e360 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -125,6 +125,7 @@ export type StoreSelections = { userSettingsLoaded?: boolean; lastUsedAgentProfileId?: string | null; lastUsedExecutorProfileId?: string | null; + effectiveWorkflowId?: string | null; }; export type DialogComputedValues = { @@ -172,7 +173,15 @@ export type DialogComputedArgs = { workspaces: Workspace[]; executors: Executor[]; repositories: Repository[]; - workflows: Array<{ id: string; agent_profile_id?: string }>; + workflows: Array<{ + id: string; + workspaceId?: string; + agent_profile_id?: string; + hidden?: boolean; + }>; + lockedWorkflow: boolean; + lastUsedWorkflowIdsByWorkspace: Record; + userSettingsLoaded?: boolean; snapshots: Record; }; diff --git a/apps/web/components/task-create-dialog-workflow-agent-effect.test.ts b/apps/web/components/task-create-dialog-workflow-agent-effect.test.ts index 18e1bfd491..109d40d74c 100644 --- a/apps/web/components/task-create-dialog-workflow-agent-effect.test.ts +++ b/apps/web/components/task-create-dialog-workflow-agent-effect.test.ts @@ -4,6 +4,10 @@ import { useWorkflowAgentProfileEffect } from "./task-create-dialog-effects"; import type { DialogFormState } from "@/components/task-create-dialog-types"; import type { AgentProfileOption } from "@/lib/state/slices"; +const BOARD_WORKFLOW_ID = "wf-board"; +const REMEMBERED_WORKFLOW_ID = "wf-remembered"; +const REMEMBERED_AGENT_ID = "remembered-agent"; + type Fake = Pick< DialogFormState, | "agentProfileId" @@ -70,3 +74,57 @@ describe("useWorkflowAgentProfileEffect - user selections", () => { expect(fsAfter.setAgentProfileId).not.toHaveBeenCalled(); }); }); + +describe("useWorkflowAgentProfileEffect - effective workflow", () => { + it("locks the agent profile from the remembered effective workflow", () => { + const fs = makeFs({ selectedWorkflowId: BOARD_WORKFLOW_ID }); + const workflows = [{ id: REMEMBERED_WORKFLOW_ID, agent_profile_id: REMEMBERED_AGENT_ID }]; + + renderHook(() => + useWorkflowAgentProfileEffect( + fs, + workflows, + [makeProfile(REMEMBERED_AGENT_ID)], + [makeProfile(REMEMBERED_AGENT_ID)], + { effectiveWorkflowId: REMEMBERED_WORKFLOW_ID }, + ), + ); + + expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith(REMEMBERED_AGENT_ID); + expect(fs.setAgentProfileId).toHaveBeenCalledWith(REMEMBERED_AGENT_ID); + }); + + it("restores the compatible last-used profile for a remembered workflow without an override", () => { + const fs = makeFs({ selectedWorkflowId: BOARD_WORKFLOW_ID }); + const rememberedAgent = makeProfile(REMEMBERED_AGENT_ID); + const workflows = [{ id: REMEMBERED_WORKFLOW_ID }]; + + renderHook(() => + useWorkflowAgentProfileEffect(fs, workflows, [rememberedAgent], [rememberedAgent], { + effectiveWorkflowId: REMEMBERED_WORKFLOW_ID, + lastUsedAgentProfileId: rememberedAgent.id, + }), + ); + + expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith(""); + expect(fs.setAgentProfileId).toHaveBeenCalledWith(rememberedAgent.id); + }); + + it("clears a remembered workflow lock when the effective workflow disappears", () => { + const fs = makeFs(); + const workflows = [{ id: REMEMBERED_WORKFLOW_ID, agent_profile_id: REMEMBERED_AGENT_ID }]; + const rememberedAgent = makeProfile(REMEMBERED_AGENT_ID); + const { rerender } = renderHook( + ({ effectiveWorkflowId }) => + useWorkflowAgentProfileEffect(fs, workflows, [rememberedAgent], [rememberedAgent], { + effectiveWorkflowId, + }), + { initialProps: { effectiveWorkflowId: REMEMBERED_WORKFLOW_ID as string | null } }, + ); + + expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith(REMEMBERED_AGENT_ID); + rerender({ effectiveWorkflowId: null }); + + expect(fs.setWorkflowAgentProfileId).toHaveBeenLastCalledWith(""); + }); +}); diff --git a/apps/web/components/workflow-selector-row.tsx b/apps/web/components/workflow-selector-row.tsx index aed61ceecb..6bf2a17571 100644 --- a/apps/web/components/workflow-selector-row.tsx +++ b/apps/web/components/workflow-selector-row.tsx @@ -87,7 +87,6 @@ type WorkflowSelectorRowProps = { snapshots: Record; selectedWorkflowId: string | null; onWorkflowChange: (workflowId: string) => void; - lastUsedWorkflowId?: string | null; agentProfiles: AgentProfileOption[]; }; @@ -96,7 +95,6 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ snapshots, selectedWorkflowId, onWorkflowChange, - lastUsedWorkflowId, agentProfiles, }: WorkflowSelectorRowProps) { const { t } = useTranslation(); @@ -107,16 +105,6 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ [workflows, selectedWorkflowId], ); - // Sort workflows: last-used first, then original order (which is already by sort_order) - const sortedWorkflows = useMemo(() => { - if (!lastUsedWorkflowId) return workflows; - return [...workflows].sort((a, b) => { - if (a.id === lastUsedWorkflowId) return -1; - if (b.id === lastUsedWorkflowId) return 1; - return 0; - }); - }, [workflows, lastUsedWorkflowId]); - return ( @@ -137,7 +125,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
{t("workflows:workflow")}
- {sortedWorkflows.map((wf) => { + {workflows.map((wf) => { const isSelected = wf.id === selectedWorkflowId; const snapshot = snapshots[wf.id]; const steps = snapshot ? [...snapshot.steps].sort((a, b) => a.position - b.position) : []; diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index f66c8567da..99090c5d4e 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -205,6 +205,7 @@ export const test = backendFixture.extend< repository_id: seedData.repositoryId, branch: "main", agent_profile_id: seedData.agentProfileId, + workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId }, }, // Reset to default kanban view. Pipeline-view tests switch this to // "graph2", which persists per-workspace; without this reset the next @@ -348,6 +349,7 @@ test.beforeEach(async ({ apiClient, seedData }) => { repository_id: seedData.repositoryId, branch: "main", agent_profile_id: seedData.agentProfileId, + workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId }, }, }); }); diff --git a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts index 7752fe48d7..e8a0518d2e 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -247,6 +247,11 @@ test.describe("Mobile kanban view", () => { await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: seedData.workflowId, + task_create_last_used: { + workflow_ids_by_workspace: { + [seedData.workspaceId]: secondWorkflow.id, + }, + }, }); const mobile = new MobileKanbanPage(testPage); diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts index e7bebd1857..9d2f40b001 100644 --- a/apps/web/e2e/tests/task/create-task.spec.ts +++ b/apps/web/e2e/tests/task/create-task.spec.ts @@ -134,6 +134,145 @@ test.describe("Task creation", () => { ); }); + test("uses the remembered workflow instead of the board filter", async ({ + testPage, + apiClient, + seedData, + }) => { + const devWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Dev", "simple"); + const devSteps = await apiClient.listWorkflowSteps(devWorkflow.id); + const devStartStep = devSteps.steps.find((step) => step.is_start_step) ?? devSteps.steps[0]; + if (!devStartStep) throw new Error("Dev workflow has no start step"); + + try { + await apiClient.createTask(seedData.workspaceId, "Remembered Dev task", { + workflow_id: devWorkflow.id, + workflow_step_id: devStartStep.id, + }); + const { settings } = await apiClient.getUserSettings(); + expect( + settings.task_create_last_used?.workflow_ids_by_workspace?.[seedData.workspaceId], + ).toBe(devWorkflow.id); + + // Keep the board filtered to the seeded workflow while remembering a + // different workflow for task creation. + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); + } finally { + await apiClient.deleteWorkflow(devWorkflow.id).catch(() => {}); + } + }); + + test("keeps remembered workflows isolated by workspace after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + let devWorkflowId = ""; + let workspaceBId = ""; + + try { + const devWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Dev", "simple"); + devWorkflowId = devWorkflow.id; + const devSteps = await apiClient.listWorkflowSteps(devWorkflow.id); + const devStartStep = devSteps.steps.find((step) => step.is_start_step) ?? devSteps.steps[0]; + if (!devStartStep) throw new Error("Dev workflow has no start step"); + + const workspaceB = await apiClient.createWorkspace("Workflow Memory B"); + workspaceBId = workspaceB.id; + const supportWorkflow = await apiClient.createWorkflow(workspaceB.id, "Support", "simple"); + const otherWorkflow = await apiClient.createWorkflow(workspaceB.id, "Other", "simple"); + const supportSteps = await apiClient.listWorkflowSteps(supportWorkflow.id); + const supportStartStep = + supportSteps.steps.find((step) => step.is_start_step) ?? supportSteps.steps[0]; + if (!supportStartStep) throw new Error("Support workflow has no start step"); + + await apiClient.createTask(seedData.workspaceId, "Remembered Dev task", { + workflow_id: devWorkflow.id, + workflow_step_id: devStartStep.id, + }); + await apiClient.createTask(workspaceB.id, "Remembered Support task", { + workflow_id: supportWorkflow.id, + workflow_step_id: supportStartStep.id, + }); + const { settings } = await apiClient.getUserSettings(); + expect(settings.task_create_last_used?.workflow_ids_by_workspace).toMatchObject({ + [seedData.workspaceId]: devWorkflow.id, + [workspaceB.id]: supportWorkflow.id, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); + await testPage.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + + await testPage.getByTestId("sidebar-workspace-trigger").click(); + await testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`).click(); + await expect(testPage).toHaveURL( + (url) => url.pathname === "/" && url.searchParams.get("workspaceId") === workspaceB.id, + ); + + // Give workspace B a conflicting board filter so the remembered Support + // workflow is the only source that can select it in the dialog. + await apiClient.saveUserSettings({ + workspace_id: workspaceB.id, + workflow_filter_id: otherWorkflow.id, + }); + await testPage.reload(); + await kanban.board.waitFor({ state: "visible" }); + await kanban.createTaskButton.first().click(); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Support"); + await testPage.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + + // Reload workspace A again to prove the two memories do not overwrite + // each other when the active workspace changes. + await testPage.getByTestId("sidebar-workspace-trigger").click(); + await testPage.getByTestId(`sidebar-workspace-item-${seedData.workspaceId}`).click(); + await expect(testPage).toHaveURL( + (url) => + url.pathname === "/" && url.searchParams.get("workspaceId") === seedData.workspaceId, + ); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + }); + await testPage.reload(); + await kanban.board.waitFor({ state: "visible" }); + await kanban.createTaskButton.first().click(); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev"); + } finally { + if (devWorkflowId) await apiClient.deleteWorkflow(devWorkflowId).catch(() => {}); + if (workspaceBId) + await apiClient.deleteWorkspace(workspaceBId, "Workflow Memory B").catch(() => {}); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + }); + } + }); + test("opens create task dialog from kanban header", async ({ testPage }) => { const kanban = new KanbanPage(testPage); await kanban.goto(); diff --git a/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts b/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts index 0f31b7bcb6..0eb3c8b23d 100644 --- a/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts @@ -177,6 +177,9 @@ test.describe("Workflow start step placement", () => { await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: workflow.id, + task_create_last_used: { + workflow_ids_by_workspace: { [seedData.workspaceId]: workflow.id }, + }, enable_preview_on_click: false, }); @@ -267,6 +270,9 @@ test.describe("Workflow start step placement", () => { await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, workflow_filter_id: workflow.id, + task_create_last_used: { + workflow_ids_by_workspace: { [seedData.workspaceId]: workflow.id }, + }, enable_preview_on_click: false, }); diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index e42a8aabd4..b55e65f3d4 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -81,6 +81,7 @@ function makeUnloadedSettings(): UserSettingsState { branch: null, agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, }, jiraSavedViews: undefined, jiraTaskPresets: undefined, @@ -162,7 +163,9 @@ describe("useEnsureUserSettings", () => { resolveFetch(userSettingsResponse()); await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); }); +}); +describe("useEnsureUserSettings — fetched settings overlays", () => { it("merges only defined queued task-create fields over fetched settings", async () => { mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ repositoryId: undefined, @@ -187,6 +190,7 @@ describe("useEnsureUserSettings", () => { branch: "main", agentProfileId: "agent-2", executorProfileId: "exec-profile-1", + workflowIdsByWorkspace: {}, synced: true, }); }); @@ -259,6 +263,7 @@ describe("useEnsureUserSettings — loaded settings overlay", () => { branch: "main", agentProfileId: "agent-1", executorProfileId: "exec-1", + workflowIdsByWorkspace: {}, }, }; mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ @@ -276,6 +281,7 @@ describe("useEnsureUserSettings — loaded settings overlay", () => { branch: "feature", agentProfileId: "agent-1", executorProfileId: "exec-1", + workflowIdsByWorkspace: {}, }); }); }); diff --git a/apps/web/hooks/use-ensure-user-settings.ts b/apps/web/hooks/use-ensure-user-settings.ts index 70d3eb0045..2f70dad42b 100644 --- a/apps/web/hooks/use-ensure-user-settings.ts +++ b/apps/web/hooks/use-ensure-user-settings.ts @@ -35,11 +35,21 @@ function mergeTaskCreateLastUsedOverlay( ): UserSettingsState { const definedPending = compactTaskCreateLastUsedOverlay(pending); if (Object.keys(definedPending).length === 0) return settings; + const pendingWorkflowIds = definedPending.workflowIdsByWorkspace; + const { workflowIdsByWorkspace: _ignored, ...scalarPending } = definedPending; return { ...settings, taskCreateLastUsed: { ...settings.taskCreateLastUsed, - ...definedPending, + ...scalarPending, + ...(pendingWorkflowIds + ? { + workflowIdsByWorkspace: { + ...settings.taskCreateLastUsed.workflowIdsByWorkspace, + ...pendingWorkflowIds, + }, + } + : {}), }, }; } diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 57d6161cdf..52666a2c86 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -147,7 +147,9 @@ describe("buildCoreFields", () => { const result = buildCoreFields(settings); expect(result.terminalFontFamily).toBeNull(); }); +}); +describe("buildCoreFields task-create last-used mapping", () => { it("does not mark an empty task-create last-used object as synced", () => { const settings = { task_create_last_used: {}, @@ -159,6 +161,7 @@ describe("buildCoreFields", () => { branch: null, agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, synced: false, }); }); @@ -176,6 +179,29 @@ describe("buildCoreFields", () => { synced: true, }); }); + + it("maps workspace-scoped workflow history and marks it as synced", () => { + const result = buildCoreFields({ + task_create_last_used: { + workflow_ids_by_workspace: { + "workspace-1": "workflow-1", + "workspace-2": "workflow-2", + }, + }, + }); + + expect(result.taskCreateLastUsed).toEqual({ + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + workflowIdsByWorkspace: { + "workspace-1": "workflow-1", + "workspace-2": "workflow-2", + }, + synced: true, + }); + }); }); describe("Azure DevOps browse preferences", () => { diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 3e53bd29eb..842ae8b272 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -64,6 +64,7 @@ export function createDefaultUserSettings(): UserSettingsState { branch: null, agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, synced: false, }, jiraSavedViews: undefined, @@ -189,7 +190,11 @@ export function taskCreateLastUsedHasValue( value: UserSettingsData["task_create_last_used"] | undefined, ) { return Boolean( - value?.repository_id || value?.branch || value?.agent_profile_id || value?.executor_profile_id, + value?.repository_id || + value?.branch || + value?.agent_profile_id || + value?.executor_profile_id || + Object.keys(value?.workflow_ids_by_workspace ?? {}).length > 0, ); } @@ -199,6 +204,7 @@ function parseTaskCreateLastUsed(value: UserSettingsData["task_create_last_used" branch: value?.branch || null, agentProfileId: value?.agent_profile_id || null, executorProfileId: value?.executor_profile_id || null, + workflowIdsByWorkspace: value?.workflow_ids_by_workspace ?? {}, synced: taskCreateLastUsedHasValue(value), }; } diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index c51b885348..f83a2d1499 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -254,6 +254,7 @@ export type TaskCreateLastUsedState = { branch: string | null; agentProfileId: string | null; executorProfileId: string | null; + workflowIdsByWorkspace: Record; synced?: boolean; }; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 8c04cb9fd1..482e9f89e4 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -40,6 +40,7 @@ export type TaskCreateLastUsedApi = { branch?: string; agent_profile_id?: string; executor_profile_id?: string; + workflow_ids_by_workspace?: Record; }; export type AppStatusBarOrderApi = { diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index bd6c775b55..797e4f2790 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -299,6 +299,7 @@ describe("user settings websocket task-create last-used", () => { branch: null, agentProfileId: null, executorProfileId: null, + workflowIdsByWorkspace: {}, synced: false, }); }); @@ -316,6 +317,25 @@ describe("user settings websocket task-create last-used", () => { }); }); + it("maps workspace-scoped workflow history from broadcasts", () => { + const store = makeStore(); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + task_create_last_used: { + workflow_ids_by_workspace: { + "workspace-1": "workflow-1", + }, + }, + }), + ); + + expect(store.getState().userSettings.taskCreateLastUsed).toMatchObject({ + workflowIdsByWorkspace: { "workspace-1": "workflow-1" }, + synced: true, + }); + }); + it("preserves task-create last-used state when broadcasts omit it", () => { const store = makeStore(); store.setState((state) => ({ @@ -327,6 +347,7 @@ describe("user settings websocket task-create last-used", () => { branch: "main", agentProfileId: "agent-1", executorProfileId: "exec-1", + workflowIdsByWorkspace: {}, synced: true, }, }, @@ -341,6 +362,7 @@ describe("user settings websocket task-create last-used", () => { branch: "main", agentProfileId: "agent-1", executorProfileId: "exec-1", + workflowIdsByWorkspace: {}, synced: true, }); }); diff --git a/docs/decisions/2026-08-08-workspace-scoped-task-create-workflow-memory.md b/docs/decisions/2026-08-08-workspace-scoped-task-create-workflow-memory.md new file mode 100644 index 0000000000..1c7b70d4f7 --- /dev/null +++ b/docs/decisions/2026-08-08-workspace-scoped-task-create-workflow-memory.md @@ -0,0 +1,69 @@ +# ADR-2026-08-08-workspace-scoped-task-create-workflow-memory: Remember Task-Create Workflows Per Workspace + +**Status:** accepted +**Date:** 2026-08-08 +**Area:** backend, frontend + +## Context + +The standard Create Task dialog receives a workflow from the current board or +list filter when one is active, but receives no workflow from All Workflows and +other unscoped entry points. Existing backend-owned task-create preferences +remember repository, branch, agent profile, and executor profile, but not the +workflow that successfully created the previous task. A single global workflow +ID would also lose the remembered choice whenever the user creates a task in a +different workspace. + +## Decision + +Extend `users.settings.task_create_last_used` with +`workflow_ids_by_workspace`, a map from workspace ID to the workflow ID used by +the most recent successful task creation in that workspace. HTTP and WebSocket +task creation update only the entry for the created task's workspace, preserving +entries for all other workspaces and the other task-create preferences. + +The backend remains the durable source of truth under ADR 0028 and ADR 0041. +The frontend may maintain the existing in-memory queued overlay until the +backend settings update arrives, but it does not persist workflow memory in +browser storage. + +For the standard Create Task dialog, workflow resolution uses this precedence: + +1. A workflow explicitly locked by a feature-specific flow. +2. A workflow selected manually in the currently open standard dialog. +3. The current workspace's valid, visible last-used workflow. +4. A valid, visible workflow supplied by the current board or list context. +5. The sole visible workflow in the workspace. +6. No selection when multiple visible workflows remain. + +A board or list filter does not override a valid last-used workflow. Missing, +deleted, hidden, or cross-workspace remembered IDs are ignored. A successful +task creation records the effective workflow; merely opening the selector, +changing it, or cancelling the dialog does not update the durable preference. + +## Consequences + +- Returning to a workspace restores that workspace's own task-create workflow, + even after tasks were created in other workspaces or another browser. +- A filtered board can display one workflow while Create Task defaults to the + workflow most recently used for task creation in that workspace. +- The JSON preference update needs a targeted nested-map merge for both SQLite + and PostgreSQL so concurrent or alternating workspace writes do not clobber + unrelated entries. +- Existing settings rows need no SQL schema migration; an absent map behaves as + an empty history. +- The frontend must validate remembered IDs against the current workspace's + visible workflows before using them. + +## Alternatives Considered + +1. **Store one global last-used workflow ID.** Rejected because creating a task + in workspace B would erase workspace A's remembered choice. +2. **Use `workflow_filter_id` as the task-create default.** Rejected because the + board/list filter expresses what the user is viewing, and the requested task + creation default must remain independent of that filter. +3. **Persist selection immediately when the picker changes.** Rejected because + cancelled dialogs would become durable history even though no task used the + workflow. +4. **Store the value in browser storage.** Rejected by ADR 0041 because portable + preferences have one backend-owned source of truth. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 37f67bca54..e35ce445fb 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -130,3 +130,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-05-homebrew-remote-helper-audit | [Preserve Remote Helpers in Homebrew Installs](2026-08-05-homebrew-remote-helper-audit.md) | accepted | infra, workflow | 2026-08-05 | | 2026-08-07-model-aware-provider-capability-resolution | [Model-aware provider capability resolution](2026-08-07-model-aware-provider-capability-resolution.md) | accepted | backend, frontend, protocol | 2026-08-07 | | 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 | +| 2026-08-08-workspace-scoped-task-create-workflow-memory | [Remember Task-Create Workflows Per Workspace](2026-08-08-workspace-scoped-task-create-workflow-memory.md) | accepted | backend, frontend | 2026-08-08 | diff --git a/docs/plans/task-create-workflow-memory/plan.md b/docs/plans/task-create-workflow-memory/plan.md new file mode 100644 index 0000000000..c55d80a4e9 --- /dev/null +++ b/docs/plans/task-create-workflow-memory/plan.md @@ -0,0 +1,229 @@ +--- +spec: docs/specs/tasks/task-create-workflow-memory.md +created: 2026-08-08 +status: done +--- + +# Implementation Plan: Task Create Workflow Memory + +## Overview + +Extend the existing backend-owned task-create preference with a targeted +per-workspace workflow map, then make the shared dialog resolver prefer that +map over unlocked board/list context. Backend contract and merge semantics land +first, frontend mapping and resolution follow, and a production-build E2E test +proves persistence across workspace switches and conflicting filters. + +## Confirmed root cause + +`TaskCreateLastUsed` and every wire/store mapper omit workflow, while +`resetTaskForm` initializes `selectedWorkflowId` only from the caller's +`workflowId`. The selector's `lastUsedWorkflowId` is component-local and only +sorts menu choices. Consequently, a dialog opened with `workflowId=null` has no +durable workflow default when multiple workflows exist, and a dialog opened +under a filter inherits that filter rather than the workflow that created the +previous task. + +## Backend + +### Per-workspace preference contract + +- Add `WorkflowIDsByWorkspace map[string]string` with JSON key + `workflow_ids_by_workspace` to + `apps/backend/internal/user/models/models.go:TaskCreateLastUsed`. +- Keep the field inside the existing `users.settings.task_create_last_used` + JSON object; no table or column migration is required. +- Treat a non-empty workflow map as a meaningful task-create patch in + `apps/backend/internal/user/service/service.go` and preserve it through DTO, + HTTP response, WebSocket event, and boot-state mapping. +- Extend `apps/backend/internal/backendapp/boot_state_routes.go` so the boot + payload exposes the complete map and marks the task-create state synced when + the map is the only populated preference. + +### Targeted SQLite and PostgreSQL merge + +- Extend `makeTaskCreateLastUsedJSONSetArgs` in + `apps/backend/internal/user/store/sqlite.go` to update one nested + `workflow_ids_by_workspace.` entry at a time. +- Extend `applyPostgresTaskCreateLastUsedPatch` with equivalent parameterized + `jsonb_set` paths. Do not serialize and replace the whole map: alternating or + concurrent task creation in two workspaces must preserve both entries. +- Preserve the existing targeted-update behavior for repository, branch, agent + profile, and executor profile, including broad user-settings writes racing + with task creation. + +### Record the effective workflow + +- Extend `buildTaskCreateLastUsedPatch` in + `apps/backend/internal/task/handlers/task_http_handlers.go` to add + `body.WorkflowID` under `body.WorkspaceID` after successful creation. +- Update the WebSocket task-create bridge in + `apps/backend/internal/task/handlers/task_ws_handlers.go` to pass + `req.WorkspaceID` and `req.WorkflowID` into the same recorder path. +- Preserve the current warning-only behavior when preference recording fails; + successful task creation is not rolled back. + +## Frontend + +### Wire mapping and queued overlay + +- Add `workflow_ids_by_workspace?: Record` to + `TaskCreateLastUsedApi` and `workflowIdsByWorkspace: Record` + to the settings store state. +- Update `apps/web/lib/ssr/user-settings.ts`, boot/HTTP/WS mapping tests, and + default settings so absent maps normalize to `{}` and existing installations + remain compatible. +- Extend the task-create queued overlay in + `apps/web/components/task-create-dialog-handlers.ts` to derive the entry from + submitted `workspace_id` and `workflow_id`. Merge queued workflow entries + into, rather than replace, the authoritative map and clear the overlay only + after every queued entry matches backend settings. +- Update `apps/web/components/state-provider.tsx` equality logic for map + contents so settings publication clears the overlay deterministically. + +### Workflow default resolution + +- Add a pure resolver in + `apps/web/components/task-create-dialog-defaults.ts` for the confirmed + precedence: locked workflow; current-dialog manual choice; valid visible + per-workspace last-used workflow; valid visible unlocked context; sole + visible workflow; otherwise no selection. +- Keep unlocked board/list `workflowId` as fallback context rather than seeding + it as an authoritative form selection. Preserve explicit locked flows by + passing the existing `lockedFields.workflow` signal through form reset and + late-value synchronization. +- Thread `taskCreateLastUsed.workflowIdsByWorkspace[workspaceId]` and user + settings readiness through `useTaskCreateDialogData` / `useDialogComputed`. + Validate every candidate against the visible workflows in the current + workspace before returning `effectiveWorkflowId`. +- Make workflow-step loading and workflow-agent override effects consume the + resolved effective workflow so a restored default receives the same steps + and agent lock as a manual selection. +- Remove the misleading component-local `lastUsedWorkflowId` sorting state; + durable task-create recency now comes from backend settings and option order + remains workflow `sort_order`. + +### Mobile design contract + +- **Desktop and mobile outcome:** standard Create Task restores the same + workspace-specific workflow regardless of viewport or current filter. +- **Nearest shipped mobile exemplar:** the existing Kanban FAB → Create Task + dialog remains the entry point and presentation baseline. +- **Presentation:** no overlay, navigation, hierarchy, scroll owner, safe-area, + focus, or touch-target changes. Exactly-one-workflow suppression remains + shared across viewports. +- **Shared logic:** wire mapping, queued overlay, and default resolution stay in + shared state/hooks; no mobile-specific preference is introduced. +- **Mobile proof:** focused unit tests cover the shared resolver and the + existing mobile Kanban workflow-context E2E continues to cover dialog + reachability. No new mobile-only Playwright scenario is required because the + change is state normalization inside unchanged composition. + +## Tests + +- **What:** HTTP and WebSocket task creation record the effective workflow under + the correct workspace key. + - **File:** `apps/backend/internal/task/handlers/task_http_handlers_test.go` + (the existing file covers both HTTP and WebSocket task-create handlers). + - **How:** extend recorder-capture tests for both transports and assert the + workspace-to-workflow entry alongside existing profile/repository fields. +- **What:** targeted task-create preference writes preserve workflow entries for + other workspaces and all existing last-used fields on SQLite and PostgreSQL + query paths. + - **File:** `apps/backend/internal/user/store/sqlite_test.go`. + - **How:** real SQLite repository test plus PostgreSQL query-builder + assertions using two workspace keys and partial patches. +- **What:** service, DTO, event, and boot payload treat workflow-only history as + loaded task-create state. + - **File:** `apps/backend/internal/user/service/service_test.go` and + `apps/backend/internal/backendapp/boot_state_routes_test.go`. + - **How:** record a workflow-only patch and assert the published/boot map and + `synced` state. +- **What:** the frontend mapper and queued overlay preserve multiple workspace + entries and clear only after backend convergence. + - **File:** `apps/web/lib/ssr/user-settings.test.ts`, + `apps/web/hooks/use-ensure-user-settings.test.ts`, + `apps/web/components/state-provider.test.tsx`, and + `apps/web/components/task-create-dialog-handlers.test.ts`. + - **How:** map missing and populated wire values, merge two workspace entries, + and exercise stale/fresh settings publication. +- **What:** workflow resolution prefers per-workspace last-used state over an + unlocked conflicting filter, while manual and locked selections win and + invalid history falls through. + - **File:** `apps/web/components/task-create-dialog-defaults.test.ts` and + focused dialog state/effect tests. + - **How:** table-driven pure resolver cases plus hook coverage proving steps + and workflow-agent overrides follow the effective workflow. +- **What:** one visible workflow remains implicit with no selector. + - **File:** existing + `apps/web/components/task-create-dialog-form-body.test.tsx` and + `apps/web/e2e/tests/task/create-task.spec.ts` regressions. + - **How:** retain the current unit and E2E assertions unchanged unless wiring + changes require fixture updates. + +## E2E Tests + +- **Scenario:** **GIVEN** workspace A remembers Dev while its board is filtered + to PR Review, **WHEN** standard Create Task opens, **THEN** Dev is selected. + - **File:** `apps/web/e2e/tests/task/create-task.spec.ts`. + - **What to verify:** create the remembered task through the real HTTP API, + persist the conflicting filter, open the dialog through the regular UI, + and assert the visible workflow trigger text. +- **Scenario:** **GIVEN** workspace A remembers Dev and workspace B remembers + Support, **WHEN** the user opens standard Create Task in each workspace, + **THEN** each workspace restores its own workflow. + - **File:** `apps/web/e2e/tests/task/create-task.spec.ts`. + - **What to verify:** seed both histories through successful task creation, + navigate between workspaces, and assert each dialog independently after a + page reload so the Go boot payload and frontend mapper are exercised. +- **Scenario:** **GIVEN** a single visible workflow, **WHEN** Create Task opens, + **THEN** no workflow selector appears. + - **File:** existing `apps/web/e2e/tests/task/create-task.spec.ts` scenario. + - **What to verify:** keep the existing hidden-workflow-plus-one-visible + regression green. + +## Verification Results + +All planned checks pass: + +- Backend: `cd apps/backend && go test ./internal/user/store ./internal/user/service ./internal/task/handlers ./internal/backendapp` — all four packages passed. +- Frontend: `cd apps/web && pnpm test -- --run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts hooks/use-ensure-user-settings.test.ts components/state-provider.test.tsx components/task-create-dialog-handlers.test.ts components/task-create-dialog-defaults.test.ts components/task-create-dialog-state.test.ts components/task-create-dialog-effects.test.ts components/task-create-dialog-form-body.test.tsx` — 9 files, 158 tests passed. +- Frontend typecheck: `cd apps/web && pnpm run typecheck` — passed. +- E2E focused production-build run: `cd apps/web && pnpm e2e:run tests/task/create-task.spec.ts -- --grep "remembered workflow"` — 2 tests passed. +- E2E selector/regression run: `cd apps/web && pnpm e2e:run --no-build tests/task/create-task.spec.ts -- --grep "single visible workflow|remembered workflow"` — 3 tests passed. +- E2E full task-create spec: `cd apps/web && pnpm e2e:run --no-build tests/task/create-task.spec.ts` — 15 tests passed in 39 seconds. +- Managed E2E runners exited cleanly and removed their isolated `/tmp/kandev-e2e-*` worker directories; no failure artifacts were produced. + +## Implementation Waves And Parallel Candidates + +Execution is sequential in the primary conversation because the frontend wire +contract depends on the backend field and the E2E scenario depends on both. +No task is marked parallel-safe and no subagent delegation is authorized. + +Wave 1: + +- [x] [Task 01: Persist per-workspace workflow memory](task-01-backend-workflow-memory.md) + +Wave 2: + +- [x] [Task 02: Restore the remembered workflow](task-02-frontend-workflow-resolution.md) + +Wave 3: + +- [x] [Task 03: Prove workflow memory through Create Task](task-03-create-task-e2e.md) + +## Risks And Out Of Scope + +- Nested JSON path construction must remain parameterized and safe for both + SQLite and PostgreSQL; replacing the complete map would reintroduce lost + updates across workspaces. +- Unlocked caller context currently doubles as form state. Separating fallback + context from manual/locked selection must preserve edit and Improve Kandev + locked flows. +- User settings can arrive after the dialog opens. The resolver must not + overwrite a manual selection made in the current open cycle when settings + settle. +- Workflow deletion does not eagerly prune history; validation ignores stale + IDs and the next successful creation overwrites that workspace entry. +- Changing the board/list filter, dialog composition, workflow ordering, and + cancelled-dialog persistence remain out of scope. diff --git a/docs/plans/task-create-workflow-memory/task-01-backend-workflow-memory.md b/docs/plans/task-create-workflow-memory/task-01-backend-workflow-memory.md new file mode 100644 index 0000000000..07c01a3fba --- /dev/null +++ b/docs/plans/task-create-workflow-memory/task-01-backend-workflow-memory.md @@ -0,0 +1,108 @@ +--- +id: "01-backend-workflow-memory" +title: "Persist per-workspace workflow memory" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/task-create-workflow-memory.md" +--- + +# Task 01: Persist Per-Workspace Workflow Memory + +## Intent + +Extend the backend-owned task-create preference so every successful HTTP or +WebSocket task creation records its effective workflow under the task's +workspace without clobbering another workspace's history. + +## Acceptance + +- `task_create_last_used.workflow_ids_by_workspace` round-trips through the + model, user-settings response/event, and boot payload; missing maps remain + backward compatible. +- SQLite and PostgreSQL patch builders update individual workspace entries and + preserve other workspace entries plus repository, branch, agent, and executor + preferences. +- Successful HTTP and WebSocket task creation record the request's workspace + and effective workflow, while preference-write failure remains non-fatal to + task creation. + +## TDD sequence + +1. Extend handler recorder tests for HTTP and WebSocket creation and confirm RED + because workflow history is absent. +2. Add SQLite repository and PostgreSQL query-builder regressions that preserve + two workspace entries across partial updates; confirm RED before store + changes. +3. Add the model, service emptiness rule, targeted JSON patches, transport + wiring, event mapping, and boot mapping. +4. Run the focused backend packages and keep every existing last-used field + regression green. + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/service/service_test.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/user/store/sqlite_test.go` +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers_test.go` +- `apps/backend/internal/task/handlers/task_ws_handlers.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/backendapp/boot_state_routes_test.go` + +## Dependencies + +None. + +## Parallelism + +`sequential` — this task owns the shared persisted JSON contract consumed by +all later tasks. + +## Inputs + +- Spec sections `Data model`, `Persistence guarantees`, and the HTTP/WebSocket + recording scenarios. +- ADR 0028, ADR 0041, and + ADR-2026-08-08-workspace-scoped-task-create-workflow-memory. +- Existing `makeTaskCreateLastUsedJSONSetArgs`, + `applyPostgresTaskCreateLastUsedPatch`, `buildTaskCreateLastUsedPatch`, and + `mapTaskCreateLastUsed` patterns. + +## Verification + +- `cd apps/backend && go test ./internal/user/store ./internal/user/service ./internal/task/handlers ./internal/backendapp` + +## Risks + +- Workspace IDs must be represented through safe JSON paths rather than raw SQL + interpolation. +- A whole-map replacement would pass a one-workspace test while losing history + during alternating workspace writes; the regression must assert two keys. + +## Output contract + +Report RED evidence, changed files, focused Go test results/counts, SQLite and +PostgreSQL path coverage, blockers, risks, and synchronize this task plus +`plan.md` status/results in the primary conversation. + +## Results + +Implemented the workspace-scoped workflow map in the existing user-settings +JSON contract. HTTP and WebSocket task creation now record the effective +workspace/workflow pair after successful creation; SQLite and PostgreSQL +updates patch individual map entries while preserving existing preferences and +other workspace history. Boot-state and service/event mappings normalize +missing maps for backwards compatibility. + +TDD RED evidence included handler recorder assertions failing with a missing +workflow map before transport wiring, followed by the store/service compile +adjustments required by the new map field. Final verification: + +`cd apps/backend && go test ./internal/user/store ./internal/user/service ./internal/task/handlers ./internal/backendapp` + +All four packages passed, including real SQLite preservation coverage and +parameterized PostgreSQL query-builder coverage. diff --git a/docs/plans/task-create-workflow-memory/task-02-frontend-workflow-resolution.md b/docs/plans/task-create-workflow-memory/task-02-frontend-workflow-resolution.md new file mode 100644 index 0000000000..9132f8948f --- /dev/null +++ b/docs/plans/task-create-workflow-memory/task-02-frontend-workflow-resolution.md @@ -0,0 +1,129 @@ +--- +id: "02-frontend-workflow-resolution" +title: "Restore the remembered workflow" +status: done +wave: 2 +depends_on: ["01-backend-workflow-memory"] +plan: "plan.md" +spec: "../../specs/tasks/task-create-workflow-memory.md" +--- + +# Task 02: Restore the Remembered Workflow + +## Intent + +Map the per-workspace backend preference into shared frontend state and resolve +standard Create Task workflows with last-used precedence without overriding a +manual choice or an explicitly locked flow. + +## Acceptance + +- Wire, store, boot/HTTP/WS, and queued-overlay paths preserve multiple + `workflowIdsByWorkspace` entries and converge after backend publication. +- Workflow resolution uses locked workflow → current-dialog manual selection → valid + per-workspace last-used workflow → valid unlocked context → sole visible + workflow → none. +- Restored workflows drive step loading and workflow-agent overrides, while the + one-visible-workflow selector suppression and locked edit/Improve Kandev flows + remain unchanged. + +## TDD sequence + +1. Add mapper and overlay tests for absent, populated, merged, stale, and synced + workflow maps; confirm RED before adding the field. +2. Add table-driven workflow resolver tests, including conflicting filter, + manual choice, locked choice, deleted/hidden history, cross-workspace + history, and sole-visible fallback; confirm RED. +3. Thread the normalized preference through dialog setup/computed state, + separate unlocked context from manual/locked form selection, and update + dependent workflow-step/agent effects. +4. Remove the component-local workflow recency sorter and run the focused + frontend tests plus typecheck. + +## Files likely touched + +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/ssr/user-settings.test.ts` +- `apps/web/hooks/use-ensure-user-settings.ts` +- `apps/web/hooks/use-ensure-user-settings.test.ts` +- `apps/web/components/state-provider.tsx` +- `apps/web/components/state-provider.test.tsx` +- `apps/web/components/task-create-dialog-handlers.ts` +- `apps/web/components/task-create-dialog-handlers.test.ts` +- `apps/web/components/task-create-dialog-defaults.ts` +- `apps/web/components/task-create-dialog-defaults.test.ts` +- `apps/web/components/task-create-dialog-types.ts` +- `apps/web/components/task-create-dialog-state.ts` +- `apps/web/components/task-create-dialog-computed.ts` +- `apps/web/components/task-create-dialog-setup.ts` +- `apps/web/components/task-create-dialog-locked-fields.ts` +- `apps/web/components/task-create-dialog-effects.ts` +- `apps/web/components/task-create-dialog-autopick.ts` +- Focused existing tests for state, setup, locked fields, effects, and form body +- `apps/web/components/task-create-dialog-form-body.tsx` +- `apps/web/components/workflow-selector-row.tsx` + +## Dependencies + +- Task 01 supplies the authoritative wire field and backend merge semantics. + +## Parallelism + +`sequential` — shared state, dialog form state, and resolver wiring overlap +throughout this task. + +## Inputs + +- Spec `What`, `Failure modes`, and workflow-resolution scenarios. +- Existing `createDefaultUserSettings`, `parseTaskCreateLastUsed`, + `mergeTaskCreateLastUsedOverlay`, `computeSingleWorkflowFallbackId`, + `useLockedFieldSync`, and `useWorkflowAgentProfileEffect` patterns. +- Task 01's final backend JSON and boot-state shapes. + +## Verification + +- `cd apps/web && pnpm test -- --run lib/ssr/user-settings.test.ts hooks/use-ensure-user-settings.test.ts components/state-provider.test.tsx components/task-create-dialog-handlers.test.ts components/task-create-dialog-defaults.test.ts components/task-create-dialog-state.test.ts components/task-create-dialog-effects.test.ts components/task-create-dialog-form-body.test.tsx` +- `cd apps/web && pnpm run typecheck` + +## Mobile parity + +The change is shared state normalization inside the existing responsive dialog. +It does not change composition, navigation, overlay geometry, scrolling, +safe-area behavior, focus, or touch targets. The current mobile Kanban FAB flow +remains the nearest exemplar and existing mobile E2E coverage remains valid; no +new mobile-only test is required. + +## Risks + +- Settings can settle after open; late restoration must not replace a workflow + the user manually chose in that open cycle. +- Unlocked context and locked workflow currently share `selectedWorkflowId`; + separating them must not weaken locked feature/edit behavior. +- Map comparisons must be content-based so overlay cleanup is not blocked by + new object identities. + +## Output contract + +Report RED evidence, changed files, focused Vitest counts, typecheck result, +mobile-parity confirmation, blockers, risks, and synchronize this task plus +`plan.md` status/results in the primary conversation. + +## Results + +Mapped the backend workflow history into SSR, HTTP, WebSocket, boot, and +queued-overlay state. The dialog now resolves locked, manual, remembered, +contextual, and sole-visible workflows in the approved order; workflow steps +and agent overrides consume the same effective ID. The component-local option +sorter was removed, and locked feature flows retain their explicit selection. + +TDD RED evidence included seven failing resolver cases before the resolver was +implemented; overlay and settings tests then covered missing, merged, stale, +and converged maps. Final verification: + +`cd apps/web && pnpm test -- --run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts hooks/use-ensure-user-settings.test.ts components/state-provider.test.tsx components/task-create-dialog-handlers.test.ts components/task-create-dialog-defaults.test.ts components/task-create-dialog-state.test.ts components/task-create-dialog-effects.test.ts components/task-create-dialog-form-body.test.tsx` + +9 files and 158 tests passed. `cd apps/web && pnpm run typecheck` also passed. +The mobile-parity contract remains unchanged: shared resolution is used by the +existing responsive dialog and no new mobile-only composition was introduced. diff --git a/docs/plans/task-create-workflow-memory/task-03-create-task-e2e.md b/docs/plans/task-create-workflow-memory/task-03-create-task-e2e.md new file mode 100644 index 0000000000..5764649b03 --- /dev/null +++ b/docs/plans/task-create-workflow-memory/task-03-create-task-e2e.md @@ -0,0 +1,103 @@ +--- +id: "03-create-task-e2e" +title: "Prove workflow memory through Create Task" +status: done +wave: 3 +depends_on: ["01-backend-workflow-memory", "02-frontend-workflow-resolution"] +plan: "plan.md" +spec: "../../specs/tasks/task-create-workflow-memory.md" +--- + +# Task 03: Prove Workflow Memory Through Create Task + +## Intent + +Exercise the real successful-create → backend settings → Go boot payload → +frontend dialog path with conflicting filters and two workspaces. + +## Acceptance + +- A workspace's remembered workflow is shown in standard Create Task even when + the board/list filter points at another workflow. +- After two workspaces record different workflows, reloading and opening Create + Task in each restores its own workflow. +- The existing one-visible-workflow scenario still renders no selector, and the + test restores worker-scoped settings or uses disposable workspaces so no + state leaks to neighbouring specs. + +## TDD sequence + +1. Add the conflicting-filter and two-workspace UI assertions using successful + HTTP task creation as setup; run the focused grep against the production + build and confirm RED because workflow history is not yet restored. +2. Complete Tasks 01 and 02, rerun the same scenarios, and confirm GREEN without + retries. +3. Run the full task-create spec to keep existing single-workflow and selection + persistence coverage green, recording managed-runner cleanup evidence. + +## Files likely touched + +- `apps/web/e2e/tests/task/create-task.spec.ts` +- `apps/web/e2e/helpers/api-client.ts` only if the existing task/workspace seed + helpers cannot express the two-workspace setup + +## Dependencies + +- Task 01 records and serves per-workspace history. +- Task 02 maps and resolves that history in the dialog. + +## Parallelism + +`sequential` — this is the end-to-end proof for both preceding tasks. + +## Inputs + +- Spec scenarios for conflicting filters, workspace switching, and sole visible + workflow suppression. +- Existing `KanbanPage`, `ApiClient.createTask`, `ApiClient.createWorkflow`, + `saveUserSettings`, and task-create dialog test IDs. +- Existing worker-scoped settings cleanup guidance in the E2E skill. + +## Verification + +- `cd apps/web && pnpm e2e:run --project chromium tests/task/create-task.spec.ts -- --grep "remembers the last workflow per workspace|selects the single visible workflow"` +- `cd apps/web && pnpm e2e:run --project chromium tests/task/create-task.spec.ts` + +## Mobile parity + +No mobile-only E2E is added because this task proves portable shared-state +resolution without changing the mobile composition or interaction. Existing +mobile Kanban tests continue to cover opening Create Task from a selected +workflow and touch reachability. + +## Risks + +- `workflow_filter_id` and task-create workflow history are both worker-scoped + persisted settings; cleanup must restore the baseline even after assertion + failure. +- A test that asserts only option order would miss the bug; assert selected + trigger text in the visible dialog after reload. + +## Output contract + +Report RED/GREEN managed-runner commands, discovered test counts, final pass +counts, artifact paths, cleanup/teardown evidence, blockers, risks, and +synchronize this task plus `plan.md` status/results in the primary conversation. + +## Results + +Added production-build E2E coverage that creates real tasks through the HTTP +API, verifies the backend recorded Dev and Support under separate workspace +keys, then opens the standard dialog through the UI with conflicting filters +and reloads each workspace. The existing one-visible-workflow selector +omission remains covered. + +Verification: + +- `cd apps/web && pnpm e2e:run tests/task/create-task.spec.ts -- --grep "remembered workflow"` — 2 passed after a managed backend/Vite build. +- `cd apps/web && pnpm e2e:run --no-build tests/task/create-task.spec.ts -- --grep "single visible workflow|remembered workflow"` — 3 passed. +- `cd apps/web && pnpm e2e:run --no-build tests/task/create-task.spec.ts` — all 15 tests passed in 39 seconds. + +The managed runner isolated and cleaned its temporary worker backends and +repositories; the test deletes its disposable workspace/workflows and restores +the seed workspace filter. No failure artifacts were produced. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 215bfaccf4..c6254f243f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -87,6 +87,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [runtime-state-publication-order](tasks/runtime-state-publication-order.md) | shipped | | [agent-generated-titles](tasks/agent-generated-titles.md) | approved | | [task-create-executor-default](tasks/task-create-executor-default.md) | approved | +| [task-create-workflow-memory](tasks/task-create-workflow-memory.md) | approved | | [prompt-attachments](tasks/prompt-attachments.md) | draft | | [sidebar-task-edit](tasks/sidebar-task-edit.md) | approved | | [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped | diff --git a/docs/specs/tasks/task-create-workflow-memory.md b/docs/specs/tasks/task-create-workflow-memory.md new file mode 100644 index 0000000000..4928bb4c3e --- /dev/null +++ b/docs/specs/tasks/task-create-workflow-memory.md @@ -0,0 +1,120 @@ +--- +status: approved +created: 2026-08-08 +owner: Kandev +decision: ADR-2026-08-08-workspace-scoped-task-create-workflow-memory +--- + +# Task Create Workflow Memory + +## Why + +Users who create tasks in workspaces with multiple workflows repeatedly have to +choose the same workflow whenever Create Task opens from All Workflows or a +different filter. The dialog should remember the workflow that actually created +the previous task in each workspace without coupling task creation to the +current board/list filter. + +## Broken behavior + +The task-create preference records repository, branch, agent profile, and +executor profile but omits workflow. An unlocked dialog therefore uses the +current page context when it has one and otherwise remains empty when multiple +workflows exist. The selector's local `lastUsedWorkflowId` only reorders options +within one mounted component; it is not a durable default. + +## What + +- The standard Create Task dialog remembers the workflow used by the most + recent successful task creation separately for each workspace. +- A valid remembered workflow is selected even when the board or task list is + currently filtered to another workflow. +- A workflow selected manually in the currently open dialog immediately becomes + the effective selection for that submission. +- Feature-specific flows that explicitly lock a workflow keep their locked + workflow and do not inherit the standard dialog's remembered default. +- Remembered workflow IDs are used only when they are visible choices in the + current workspace. Missing, deleted, hidden, or cross-workspace IDs are + ignored. +- Without a valid remembered workflow, the dialog falls back to a valid visible + workflow supplied by its page context, then to the sole visible workflow. +- When exactly one visible workflow exists, that workflow is implicit and the + workflow selector is omitted. +- When multiple visible workflows exist and neither remembered nor contextual + defaults are valid, the selector remains visible and requires a choice. +- HTTP and WebSocket task creation record the effective workflow only after the + task is created successfully. +- Cancelling a dialog or changing the selector without creating a task does not + alter the remembered workflow. +- Desktop and mobile use the same workflow-resolution policy. The feature does + not change dialog layout, navigation, scrolling, or touch behavior. + +## Data model + +`users.settings.task_create_last_used` gains: + +```text +workflow_ids_by_workspace object + key: workspace ID + value: workflow ID used by the latest successful task creation in that workspace +``` + +The map is optional for compatibility with existing settings rows. An absent or +empty map means there is no remembered workflow. Updating one workspace entry +must preserve all other entries and the existing repository, branch, agent +profile, and executor profile fields. + +## Failure modes + +- If user settings are unavailable, the dialog does not invent or persist a + browser-local workflow default; it uses the valid contextual or sole-workflow + fallback once available. +- If a remembered workflow no longer belongs to the current visible workflow + set, the dialog ignores it and continues through the fallback order. +- Failure to record last-used settings does not roll back an otherwise + successful task creation; it follows the existing warning-only task-create + preference behavior. + +## Persistence guarantees + +Workflow memory is a portable backend-owned user preference under +[ADR 0028](../../decisions/0028-task-create-last-used-source-of-truth.md), +[ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md), and +[ADR-2026-08-08-workspace-scoped-task-create-workflow-memory](../../decisions/2026-08-08-workspace-scoped-task-create-workflow-memory.md). +It survives backend restarts, browser changes, and workspace switches. The +frontend's queued overlay is transient and only bridges the interval between a +successful create response and the authoritative settings update. + +## Scenarios + +- **GIVEN** workspace A remembers Dev and the board is filtered to PR Review, + **WHEN** the user opens standard Create Task, **THEN** Dev is selected. +- **GIVEN** workspace A remembers Dev and workspace B remembers Support, + **WHEN** the user switches between the workspaces and opens standard Create + Task in each, **THEN** each workspace restores its own remembered workflow. +- **GIVEN** Dev is restored and the user manually chooses PR Review, **WHEN** + the user successfully creates the task, **THEN** the task uses PR Review and + the next standard Create Task in that workspace restores PR Review. +- **GIVEN** a feature-specific task-create flow locks Support, **WHEN** the + workspace remembers Dev, **THEN** Support remains selected and locked. +- **GIVEN** a remembered workflow was deleted or hidden, **WHEN** standard + Create Task opens from a valid visible workflow context, **THEN** the context + workflow is selected. +- **GIVEN** exactly one visible workflow and no valid remembered workflow, + **WHEN** standard Create Task opens, **THEN** the workflow is implicit and no + workflow selector is rendered. +- **GIVEN** several visible workflows and no valid remembered or contextual + workflow, **WHEN** standard Create Task opens, **THEN** the workflow selector + is visible with no selected workflow. +- **GIVEN** the user changes the workflow and cancels Create Task, **WHEN** the + dialog is opened again, **THEN** the previously successful workflow remains + the remembered default. + +## Out of scope + +- Changing the active board/list workflow filter when Create Task resolves a + different remembered workflow. +- Remembering workflow choices from cancelled or failed task creation. +- Exposing workflow history in Settings. +- Changing hidden-workflow rules or feature-specific locked task-create flows. +- Changing the task-create dialog's desktop or mobile composition.