fix: remember task-create workflows per workspace (#2428)

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