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