fix: remember task-create workflows per workspace (#2428)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -82,6 +82,33 @@ describe("StateProvider task-create queued overlay", () => {
|
||||
branch: "main",
|
||||
agentProfileId: null,
|
||||
executorProfileId: null,
|
||||
workflowIdsByWorkspace: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div>ready</div>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
expect(readQueuedTaskCreateLastUsedState()).toEqual({});
|
||||
});
|
||||
|
||||
it("compares workflow history by content when clearing the queued overlay", () => {
|
||||
syncTaskCreateLastUsed({ workspace_id: "workspace-1", workflow_id: "workflow-1" });
|
||||
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
userSettings: {
|
||||
...defaultSettingsState.userSettings,
|
||||
loaded: true,
|
||||
taskCreateLastUsed: {
|
||||
repositoryId: null,
|
||||
branch: null,
|
||||
agentProfileId: null,
|
||||
executorProfileId: null,
|
||||
workflowIdsByWorkspace: { "workspace-1": "workflow-1" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -77,6 +77,15 @@ function clearQueuedTaskCreateLastUsed(state: AppState) {
|
||||
function taskCreateLastUsedEqual(
|
||||
a: AppState["userSettings"]["taskCreateLastUsed"],
|
||||
b: AppState["userSettings"]["taskCreateLastUsed"],
|
||||
) {
|
||||
return taskCreateLastUsedScalarsEqual(a, b)
|
||||
? taskCreateWorkflowMapsEqual(a?.workflowIdsByWorkspace, b?.workflowIdsByWorkspace)
|
||||
: false;
|
||||
}
|
||||
|
||||
function taskCreateLastUsedScalarsEqual(
|
||||
a: AppState["userSettings"]["taskCreateLastUsed"],
|
||||
b: AppState["userSettings"]["taskCreateLastUsed"],
|
||||
) {
|
||||
return (
|
||||
a?.repositoryId === b?.repositoryId &&
|
||||
@@ -87,6 +96,17 @@ function taskCreateLastUsedEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function taskCreateWorkflowMapsEqual(
|
||||
a: Record<string, string> | undefined,
|
||||
b: Record<string, string> | 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<T>(selector: (state: AppState) => T) {
|
||||
const store = useContext(StoreContext);
|
||||
if (!store) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<TaskCreateLastUsedState>)
|
||||
}
|
||||
|
||||
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<TaskCreateLastUsedState>,
|
||||
patch: Partial<TaskCreateLastUsedState>,
|
||||
): Partial<TaskCreateLastUsedState> {
|
||||
const merged = { ...previous, ...patch };
|
||||
if (patch.workflowIdsByWorkspace) {
|
||||
merged.workflowIdsByWorkspace = {
|
||||
...(previous.workflowIdsByWorkspace ?? {}),
|
||||
...patch.workflowIdsByWorkspace,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function taskCreateLastUsedPayloadBranch(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,6 +69,7 @@ type FormResetEffectsArgs = {
|
||||
setCurrentDefaults: (v: { name: string; description: string }) => void;
|
||||
setOpenCycle: React.Dispatch<React.SetStateAction<number>>;
|
||||
prevOpenRef: React.RefObject<boolean>;
|
||||
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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
userSettingsLoaded?: boolean;
|
||||
snapshots: Record<string, WorkflowSnapshotData>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,6 @@ type WorkflowSelectorRowProps = {
|
||||
snapshots: Record<string, WorkflowSnapshotData>;
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -137,7 +125,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
|
||||
{t("workflows:workflow")}
|
||||
</div>
|
||||
{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) : [];
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export type TaskCreateLastUsedState = {
|
||||
branch: string | null;
|
||||
agentProfileId: string | null;
|
||||
executorProfileId: string | null;
|
||||
workflowIdsByWorkspace: Record<string, string>;
|
||||
synced?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export type TaskCreateLastUsedApi = {
|
||||
branch?: string;
|
||||
agent_profile_id?: string;
|
||||
executor_profile_id?: string;
|
||||
workflow_ids_by_workspace?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AppStatusBarOrderApi = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.<workspace_id>` 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<string, string>` to
|
||||
`TaskCreateLastUsedApi` and `workflowIdsByWorkspace: Record<string, string>`
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<string, string>
|
||||
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.
|
||||
Reference in New Issue
Block a user