From cc6077fd7f489156672f5e3730006f47a61bd06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:01:29 +0200 Subject: [PATCH] feat: make new task auto-focus optional (#3653) --- .../internal/backendapp/boot_state_routes.go | 1 + .../backendapp/creation_auto_focus_test.go | 15 ++ .../internal/settingscatalog/defaults.go | 2 +- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/models/models.go | 1 + .../user/service/creation_auto_focus_test.go | 42 ++++ apps/backend/internal/user/service/service.go | 5 + .../user/store/creation_auto_focus_test.go | 37 ++++ apps/backend/internal/user/store/sqlite.go | 6 + .../app-sidebar/app-sidebar-footer.tsx | 6 +- .../app-sidebar-new-task-item.test.tsx | 15 +- .../app-sidebar/app-sidebar-new-task-item.tsx | 5 +- .../azure-devops-task-launcher.test.tsx | 19 +- .../azure-devops-task-launcher.tsx | 29 ++- .../canvas-task-create-launcher.test.tsx | 16 +- .../canvas/canvas-task-create-launcher.tsx | 8 +- .../creation-auto-focus-launchers.test.tsx | 52 +++++ .../my-github/quick-task-launcher.test.tsx | 47 +++-- .../github/my-github/quick-task-launcher.tsx | 4 +- .../my-gitlab/quick-task-launcher.test.tsx | 23 ++- .../gitlab/my-gitlab/quick-task-launcher.tsx | 8 +- .../improve-kandev-dialog-create.tsx | 2 +- .../components/improve-kandev-dialog.test.tsx | 45 ++++- apps/web/components/improve-kandev-dialog.tsx | 8 +- .../jira/my-jira/quick-task-launcher.tsx | 4 +- .../linear/linear-quick-task-launcher.tsx | 4 +- .../navigation/app-nav-sections.tsx | 4 +- .../creation-auto-focus-settings.test.tsx | 115 +++++++++++ .../settings/creation-auto-focus-settings.tsx | 74 +++++++ .../components/settings/general-settings.tsx | 2 + .../task-create-dialog-helpers.test.ts | 51 +++++ .../components/task-create-dialog-helpers.ts | 4 +- .../task-create-dialog-submit.test.tsx | 48 +++++ .../components/task-create-dialog-submit.tsx | 28 ++- .../components/task-create-dialog-types.ts | 4 +- .../components/task-create-dialog.test.tsx | 21 +- apps/web/components/task-create-dialog.tsx | 35 +++- .../task/dockview-header-actions.tsx | 7 +- .../session-task-switcher-sheet-dialogs.tsx | 4 + .../session-task-switcher-sheet-hooks.ts | 10 +- .../mobile/session-task-switcher-sheet.tsx | 17 ++ apps/web/components/task/new-task-button.tsx | 2 +- .../web/components/task/new-task-dropdown.tsx | 2 +- apps/web/e2e/helpers/api-client.ts | 1 + .../tests/task/creation-auto-focus-helpers.ts | 157 +++++++++++++++ .../tests/task/creation-auto-focus.spec.ts | 13 ++ .../task/mobile-creation-auto-focus.spec.ts | 16 ++ .../hooks/use-ensure-user-settings.test.ts | 1 + .../settings-discovery/catalog/preferences.ts | 11 ++ .../contract.generated.json | 16 ++ .../profile-contract.generated.json | 1 + apps/web/lib/ssr/user-settings.test.ts | 14 ++ apps/web/lib/ssr/user-settings.ts | 2 + apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/types/http-user-settings.ts | 2 + apps/web/src/locales/en/settings.json | 4 +- apps/web/src/locales/pseudo/settings.json | 4 +- apps/web/src/locales/pt-pt/settings.json | 4 +- apps/web/src/locales/zh-cn/settings.json | 4 +- apps/web/src/locales/zh-hk/settings.json | 4 +- apps/web/src/locales/zh-tw/settings.json | 4 +- docs/plans/task-creation-auto-focus/plan.md | 131 ++++++++++++ .../task-01-auto-focus-preference.md | 186 ++++++++++++++++++ docs/public/tasks-and-workflows.md | 11 ++ .../tasks/requirements/creation-auto-focus.md | 44 +++++ .../system-design/creation-auto-focus.md | 117 +++++++++++ 67 files changed, 1486 insertions(+), 98 deletions(-) create mode 100644 apps/backend/internal/backendapp/creation_auto_focus_test.go create mode 100644 apps/backend/internal/user/service/creation_auto_focus_test.go create mode 100644 apps/backend/internal/user/store/creation_auto_focus_test.go create mode 100644 apps/web/components/creation-auto-focus-launchers.test.tsx create mode 100644 apps/web/components/settings/creation-auto-focus-settings.test.tsx create mode 100644 apps/web/components/settings/creation-auto-focus-settings.tsx create mode 100644 apps/web/e2e/tests/task/creation-auto-focus-helpers.ts create mode 100644 apps/web/e2e/tests/task/creation-auto-focus.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-creation-auto-focus.spec.ts create mode 100644 docs/plans/task-creation-auto-focus/plan.md create mode 100644 docs/plans/task-creation-auto-focus/task-01-auto-focus-preference.md create mode 100644 docs/specs/tasks/requirements/creation-auto-focus.md create mode 100644 docs/specs/tasks/system-design/creation-auto-focus.md diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 50583199e8..36374b27c4 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -692,6 +692,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "preventAutoStartAgentOnOpen": settings.PreventAutoStartAgentOnOpen, "unreadDivider": settings.UnreadDivider, "agentGeneratedTaskTitles": settings.AgentGeneratedTaskTitles, + "autoFocusNewTasks": settings.AutoFocusNewTasks, "mcpTaskAgentProfileDefault": usermodels.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), "showAnchoredPromptBar": settings.ShowAnchoredPromptBar, "showScrollToLastPrompt": settings.ShowScrollToLastPrompt, diff --git a/apps/backend/internal/backendapp/creation_auto_focus_test.go b/apps/backend/internal/backendapp/creation_auto_focus_test.go new file mode 100644 index 0000000000..84aeaaf7cb --- /dev/null +++ b/apps/backend/internal/backendapp/creation_auto_focus_test.go @@ -0,0 +1,15 @@ +package backendapp + +import ( + userdto "github.com/kandev/kandev/internal/user/dto" + "testing" +) + +func TestAutoFocusNewTasksBootProjection(t *testing.T) { + for _, enabled := range []bool{true, false} { + got := mapUserSettingsState(userdto.UserSettingsResponse{Settings: userdto.UserSettingsDTO{AutoFocusNewTasks: enabled}}, "workspace") + if got["autoFocusNewTasks"] != enabled { + t.Fatalf("boot autoFocusNewTasks = %#v, want %v", got["autoFocusNewTasks"], enabled) + } + } +} diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index ff1e1bcbed..dde311b43b 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -632,7 +632,7 @@ func userSettingsDomain() DomainDescriptor { "tasks_list_group", "task grouping", "string", "tasks_list_show_details", "task details", "boolean", "initial_setup_complete", "setup complete", "boolean", "preferred_shell", "preferred shell", "string", "default_editor_id", "default editor", "string", "enable_preview_on_click", "preview on click", "boolean", "chat_submit_key", "chat submit key", "string", "review_auto_mark_on_scroll", "review auto mark", "boolean", "confirm_task_archive", "confirm task archive", "boolean", - "prevent_auto_start_agent_on_open", "prevent auto start", "boolean", "unread_divider", "unread divider", "boolean", "agent_generated_task_titles", "generated titles", "boolean", + "prevent_auto_start_agent_on_open", "prevent auto start", "boolean", "unread_divider", "unread divider", "boolean", "agent_generated_task_titles", "generated titles", "boolean", "auto_focus_new_tasks", "auto focus new tasks", "boolean", "mcp_task_agent_profile_default", "MCP task profile", "string", "show_anchored_prompt_bar", "anchored prompt bar", "boolean", "show_scroll_to_last_prompt", "scroll to last prompt", "boolean", "show_scroll_to_start", "scroll to start", "boolean", "show_transcript_auto_scroll_control", "transcript scroll control", "boolean", "show_todo_list_panel", "todo list panel", "boolean", "show_todo_list_panel_only_when_not_empty", "todo list empty state", "boolean", "show_release_notification", "release notification", "boolean", "release_notes_last_seen_version", "release notes version", "string", diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index bd85e2ffb0..9610832cad 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -102,6 +102,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS PreventAutoStartAgentOnOpen: req.PreventAutoStartAgentOnOpen, UnreadDivider: req.UnreadDivider, AgentGeneratedTaskTitles: req.AgentGeneratedTaskTitles, + AutoFocusNewTasks: req.AutoFocusNewTasks, MCPTaskAgentProfileDefault: req.MCPTaskAgentProfileDefault, ShowAnchoredPromptBar: req.ShowAnchoredPromptBar, ShowScrollToLastPrompt: req.ShowScrollToLastPrompt, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index ec3b754c4d..f408f9f2b1 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -34,6 +34,7 @@ type UserSettingsDTO struct { PreventAutoStartAgentOnOpen bool `json:"prevent_auto_start_agent_on_open"` UnreadDivider bool `json:"unread_divider"` AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` + AutoFocusNewTasks bool `json:"auto_focus_new_tasks"` MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` @@ -145,6 +146,7 @@ type UpdateUserSettingsRequest struct { PreventAutoStartAgentOnOpen *bool `json:"prevent_auto_start_agent_on_open,omitempty"` UnreadDivider *bool `json:"unread_divider,omitempty"` AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles,omitempty"` + AutoFocusNewTasks *bool `json:"auto_focus_new_tasks,omitempty"` MCPTaskAgentProfileDefault *string `json:"mcp_task_agent_profile_default,omitempty"` ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar,omitempty"` ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt,omitempty"` @@ -340,6 +342,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { PreventAutoStartAgentOnOpen: settings.PreventAutoStartAgentOnOpen, UnreadDivider: settings.UnreadDivider, AgentGeneratedTaskTitles: settings.AgentGeneratedTaskTitles, + AutoFocusNewTasks: settings.AutoFocusNewTasks, MCPTaskAgentProfileDefault: models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), ShowAnchoredPromptBar: settings.ShowAnchoredPromptBar, ShowScrollToLastPrompt: settings.ShowScrollToLastPrompt, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 07ff4ce113..45f6be8cdc 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -128,6 +128,7 @@ type UserSettings struct { PreventAutoStartAgentOnOpen bool `json:"prevent_auto_start_agent_on_open"` UnreadDivider bool `json:"unread_divider"` AgentGeneratedTaskTitles bool `json:"agent_generated_task_titles"` + AutoFocusNewTasks bool `json:"auto_focus_new_tasks"` MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` ShowAnchoredPromptBar bool `json:"show_anchored_prompt_bar"` // desktop-only sticky last-prompt bar ShowScrollToLastPrompt bool `json:"show_scroll_to_last_prompt"` diff --git a/apps/backend/internal/user/service/creation_auto_focus_test.go b/apps/backend/internal/user/service/creation_auto_focus_test.go new file mode 100644 index 0000000000..64f9dc465f --- /dev/null +++ b/apps/backend/internal/user/service/creation_auto_focus_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "context" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/dto" + "github.com/kandev/kandev/internal/user/models" + "go.uber.org/zap" + "testing" +) + +func TestAutoFocusNewTasksPatchAndPublication(t *testing.T) { + settings := &models.UserSettings{AutoFocusNewTasks: true} + disabled := false + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{AutoFocusNewTasks: &disabled}); err != nil { + t.Fatal(err) + } + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatal(err) + } + if settings.AutoFocusNewTasks || dto.FromUserSettings(settings).AutoFocusNewTasks { + t.Fatal("explicit false was lost") + } + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + bus := &recordingEventBus{} + svc := NewService(&recordingUserRepository{}, bus, log) + svc.publishUserSettingsEvent(context.Background(), settings) + data := bus.publishedEvents[0].Data.(map[string]interface{}) + if got := data["auto_focus_new_tasks"]; got != false { + t.Fatalf("event value = %#v, want false", got) + } + enabled := true + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{AutoFocusNewTasks: &enabled}); err != nil { + t.Fatal(err) + } + if !settings.AutoFocusNewTasks { + t.Fatal("explicit true was lost") + } +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index e0ba9d3c71..7394f3387e 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -62,6 +62,7 @@ type UpdateUserSettingsRequest struct { PreventAutoStartAgentOnOpen *bool UnreadDivider *bool AgentGeneratedTaskTitles *bool + AutoFocusNewTasks *bool MCPTaskAgentProfileDefault *string ShowAnchoredPromptBar *bool ShowScrollToLastPrompt *bool @@ -628,6 +629,9 @@ func applyTaskActionPreferences(settings *models.UserSettings, req *UpdateUserSe if req.AgentGeneratedTaskTitles != nil { settings.AgentGeneratedTaskTitles = *req.AgentGeneratedTaskTitles } + if req.AutoFocusNewTasks != nil { + settings.AutoFocusNewTasks = *req.AutoFocusNewTasks + } if err := applyMCPTaskAgentProfileDefault(settings, req.MCPTaskAgentProfileDefault); err != nil { return err } @@ -1079,6 +1083,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "prevent_auto_start_agent_on_open": settings.PreventAutoStartAgentOnOpen, "unread_divider": settings.UnreadDivider, "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, + "auto_focus_new_tasks": settings.AutoFocusNewTasks, "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, diff --git a/apps/backend/internal/user/store/creation_auto_focus_test.go b/apps/backend/internal/user/store/creation_auto_focus_test.go new file mode 100644 index 0000000000..1a49a8413d --- /dev/null +++ b/apps/backend/internal/user/store/creation_auto_focus_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "encoding/json" + "testing" +) + +// @covers AC-TASKS-CREATION-AUTO-FOCUS-001.1, AC-TASKS-CREATION-AUTO-FOCUS-001.4 +func TestAutoFocusNewTasksStoredDefaultAndRoundTrip(t *testing.T) { + for _, tc := range []struct { + name, raw string + want bool + }{ + {"legacy empty", `{}`, true}, + {"legacy other fields", `{"chat_submit_key":"cmd_enter"}`, true}, + {"disabled", `{"auto_focus_new_tasks":false}`, false}, + {"enabled", `{"auto_focus_new_tasks":true}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: tc.raw}, DefaultUserID) + if err != nil { + t.Fatal(err) + } + raw, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + if got := payload["auto_focus_new_tasks"]; got != tc.want { + t.Fatalf("auto_focus_new_tasks = %#v, want %v", got, tc.want) + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 7d13121d32..3c716a6026 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -629,6 +629,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "prevent_auto_start_agent_on_open": settings.PreventAutoStartAgentOnOpen, "unread_divider": settings.UnreadDivider, "agent_generated_task_titles": settings.AgentGeneratedTaskTitles, + "auto_focus_new_tasks": settings.AutoFocusNewTasks, "mcp_task_agent_profile_default": models.NormalizeMCPTaskAgentProfileDefault(settings.MCPTaskAgentProfileDefault), "show_anchored_prompt_bar": settings.ShowAnchoredPromptBar, "show_scroll_to_last_prompt": settings.ShowScrollToLastPrompt, @@ -732,6 +733,7 @@ func defaultUserSettings(userID string) *models.UserSettings { ConfirmTaskArchive: true, UnreadDivider: false, AgentGeneratedTaskTitles: true, + AutoFocusNewTasks: true, MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultCurrentTask, ShowAnchoredPromptBar: false, ShowScrollToLastPrompt: true, @@ -811,6 +813,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin PreventAutoStartAgentOnOpen *bool `json:"prevent_auto_start_agent_on_open"` UnreadDivider *bool `json:"unread_divider"` AgentGeneratedTaskTitles *bool `json:"agent_generated_task_titles"` + AutoFocusNewTasks *bool `json:"auto_focus_new_tasks"` MCPTaskAgentProfileDefault string `json:"mcp_task_agent_profile_default"` ShowAnchoredPromptBar *bool `json:"show_anchored_prompt_bar"` ShowScrollToLastPrompt *bool `json:"show_scroll_to_last_prompt"` @@ -896,6 +899,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if payload.AgentGeneratedTaskTitles != nil { settings.AgentGeneratedTaskTitles = *payload.AgentGeneratedTaskTitles } + if payload.AutoFocusNewTasks != nil { + settings.AutoFocusNewTasks = *payload.AutoFocusNewTasks + } settings.MCPTaskAgentProfileDefault = models.NormalizeMCPTaskAgentProfileDefault(payload.MCPTaskAgentProfileDefault) if payload.ShowAnchoredPromptBar != nil { settings.ShowAnchoredPromptBar = *payload.ShowAnchoredPromptBar diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 573331810b..c8cb5e1853 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -159,7 +159,7 @@ function SidebarFooterDialogs({ improveOpen: boolean; onImproveOpenChange: (open: boolean) => void; workspaceId: string | null; - onTaskCreated: (task: { id: string }) => void; + onTaskCreated: (task: { id: string }, meta?: { autoFocus?: boolean }) => void; releaseNotes: ReturnType; }) { return ( @@ -414,7 +414,9 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar improveOpen={improveOpen} onImproveOpenChange={setImproveOpen} workspaceId={workspaceId ?? null} - onTaskCreated={(task) => router.push(linkToTask(task.id))} + onTaskCreated={(task, meta) => { + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); + }} releaseNotes={releaseNotes} /> diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx index f1ef993b73..409c7441ff 100644 --- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ openQuickTerminal: vi.fn(), dialogTaskSessionId: null as string | null, dialogWillNavigate: false, + dialogAutoFocus: true, })); function renderItem(collapsed: boolean) { @@ -106,7 +107,7 @@ vi.mock("@/components/task-create-dialog", () => ({ onSuccess?: ( task: { id: string }, mode: "create" | "edit", - meta?: { taskSessionId?: string | null; willNavigate?: boolean }, + meta?: { taskSessionId?: string | null; willNavigate?: boolean; autoFocus?: boolean }, ) => void; }) => ( @@ -129,6 +133,7 @@ function renderLauncher() { beforeEach(() => { vi.clearAllMocks(); + mocks.autoFocus = true; }); afterEach(cleanup); @@ -288,3 +293,13 @@ it("reports a missing work-item project after creating the task", async () => { ); expect(mocks.associateWorkItem).not.toHaveBeenCalled(); }); + +it("links a background task without navigating", async () => { + mocks.autoFocus = false; + mocks.associate.mockResolvedValue({}); + renderLauncher(); + fireEvent.click(screen.getByRole("button", { name: createTaskButtonName })); + await waitFor(() => expect(mocks.close).toHaveBeenCalled()); + expect(mocks.associate).toHaveBeenCalled(); + expect(mocks.push).not.toHaveBeenCalled(); +}); diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index a783918d73..fdfee890f3 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -105,6 +105,16 @@ function matchingRepository( ); } +function findLaunchWorkflow(workflows: Workflow[], steps: WorkflowStep[]) { + const workflow = workflows.find((candidate) => + steps.some((step) => step.workflow_id === candidate.id), + ); + const workflowSteps = steps + .filter((step) => step.workflow_id === workflow?.id) + .sort((left, right) => left.position - right.position); + return { workflow, workflowSteps }; +} + export function AzureDevOpsTaskLauncher({ workspaceId, workflows, @@ -125,12 +135,7 @@ export function AzureDevOpsTaskLauncher({ const setTaskWorkItem = useAppStore((state) => state.setAzureDevOpsTaskWorkItem); const launch = useMemo(() => { if (!payload) return null; - const workflow = workflows.find((candidate) => - steps.some((step) => step.workflow_id === candidate.id), - ); - const workflowSteps = steps - .filter((step) => step.workflow_id === workflow?.id) - .sort((left, right) => left.position - right.position); + const { workflow, workflowSteps } = findLaunchWorkflow(workflows, steps); return { workflow, workflowSteps, @@ -139,7 +144,7 @@ export function AzureDevOpsTaskLauncher({ }; }, [payload, repositories, steps, workflows]); - const onSuccess = async (task: Task) => { + const linkCreatedTask = async (task: Task) => { if (payload?.kind === "work-item" && workspaceId) { if (!payload.item.project) { toast.error(t("azuredevops:failedToLinkWorkItemNoProject")); @@ -172,8 +177,16 @@ export function AzureDevOpsTaskLauncher({ ); } } + }; + + const onSuccess = async ( + task: Task, + _mode?: "create" | "edit", + meta?: { autoFocus?: boolean }, + ) => { + await linkCreatedTask(task); onClose(); - router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); }; if (!workspaceId || !payload || !launch?.workflow || !launch.workflowSteps[0]) return null; diff --git a/apps/web/components/canvas/canvas-task-create-launcher.test.tsx b/apps/web/components/canvas/canvas-task-create-launcher.test.tsx index ec14b007a8..a6bc1bc992 100644 --- a/apps/web/components/canvas/canvas-task-create-launcher.test.tsx +++ b/apps/web/components/canvas/canvas-task-create-launcher.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -18,7 +18,11 @@ type DialogProps = { preferLocalExecutor?: boolean; }; lockedFields?: { workflow?: boolean; repository?: boolean }; - onSuccess?: (task: { id: string }, mode: "create" | "edit") => void; + onSuccess?: ( + task: { id: string }, + mode: "create" | "edit", + meta?: { autoFocus?: boolean }, + ) => void; }; const CREATE_CANVAS_LABEL = "Create canvas"; @@ -193,3 +197,11 @@ describe("CanvasTaskCreateLauncher", () => { expect(mocks.dialogProps).toBeNull(); }); }); + +it("closes canvas creation without navigating when auto-focus is disabled", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: SET_UP_CANVAS_LABEL })); + act(() => mocks.dialogProps?.onSuccess?.({ id: "task-1" }, "create", { autoFocus: false })); + expect(mocks.push).not.toHaveBeenCalled(); + expect(screen.queryByTestId("canvas-dialog-submit")).toBeNull(); +}); diff --git a/apps/web/components/canvas/canvas-task-create-launcher.tsx b/apps/web/components/canvas/canvas-task-create-launcher.tsx index ffe5755526..a8359289d4 100644 --- a/apps/web/components/canvas/canvas-task-create-launcher.tsx +++ b/apps/web/components/canvas/canvas-task-create-launcher.tsx @@ -81,10 +81,14 @@ export function CanvasTaskCreateLauncher({ }, [dialogWorkspaceId, enabled, workspaceId]); const handleSuccess = useCallback( - (task: Task, _mode: "create" | "edit", meta?: { willNavigate?: boolean }) => { + ( + task: Task, + _mode: "create" | "edit", + meta?: { willNavigate?: boolean; autoFocus?: boolean }, + ) => { setOpen(false); setDialogWorkspaceId(null); - if (!meta?.willNavigate) router.push(linkToTask(task.id)); + if (meta?.autoFocus !== false && !meta?.willNavigate) router.push(linkToTask(task.id)); }, [router], ); diff --git a/apps/web/components/creation-auto-focus-launchers.test.tsx b/apps/web/components/creation-auto-focus-launchers.test.tsx new file mode 100644 index 0000000000..2d2f4a6e27 --- /dev/null +++ b/apps/web/components/creation-auto-focus-launchers.test.tsx @@ -0,0 +1,52 @@ +import { render, cleanup } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { TaskCreateDialogProps } from "./task-create-dialog-types"; +import { LinearQuickTaskLauncher } from "./linear/linear-quick-task-launcher"; +import { QuickTaskLauncher as JiraQuickTaskLauncher } from "./jira/my-jira/quick-task-launcher"; + +const mocks = vi.hoisted(() => ({ push: vi.fn(), props: null as TaskCreateDialogProps | null })); +vi.mock("@/components/task-create-dialog", () => ({ + TaskCreateDialog: (props: TaskCreateDialogProps) => { + mocks.props = props; + return null; + }, +})); +vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mocks.push }) })); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +it.each(["linear", "jira"])( + "closes %s task creation without navigating when auto-focus is off", + (provider) => { + const onClose = vi.fn(); + const common = { + workspaceId: "ws", + workflows: [{ id: "wf" }] as never, + steps: [{ id: "step", workflow_id: "wf", position: 0 }] as never, + onClose, + }; + if (provider === "linear") + render( + , + ); + else + render( + "Issue" }, + } as never + } + />, + ); + mocks.props?.onSuccess?.({ id: "task" } as never, "create", { autoFocus: false }); + expect(onClose).toHaveBeenCalled(); + expect(mocks.push).not.toHaveBeenCalled(); + }, +); diff --git a/apps/web/components/github/my-github/quick-task-launcher.test.tsx b/apps/web/components/github/my-github/quick-task-launcher.test.tsx index 5f820df673..7e630df2cd 100644 --- a/apps/web/components/github/my-github/quick-task-launcher.test.tsx +++ b/apps/web/components/github/my-github/quick-task-launcher.test.tsx @@ -24,7 +24,10 @@ const LOCAL_REPO_ID = "local-repo"; const mocks = vi.hoisted(() => ({ dialogProps: undefined as - | { initialValues?: Record; onSuccess?: (task: Task) => void } + | { + initialValues?: Record; + onSuccess?: (task: Task, mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => void; + } | undefined, push: vi.fn(), createTaskPR: vi.fn(), @@ -267,27 +270,31 @@ describe("QuickTaskLauncher repository defaults", () => { }); describe("QuickTaskLauncher issue linking", () => { - it("links and immediately stores a newly created task for the launched issue", async () => { - const link = { - task_id: "task-1", - task_title: "Review: add Download option", - owner: "kdlbs", - repo: "kandev", - issue_number: 1567, - issue_url: ISSUE_URL, - issue_title: "add Download option", - }; - mocks.linkTaskIssue.mockResolvedValue(link); - renderIssueLauncher([repo({ id: LOCAL_REPO_ID })]); + it.each([true, false])( + "links and stores the launched issue with auto-focus %s", + async (autoFocus) => { + const link = { + task_id: "task-1", + task_title: "Review: add Download option", + owner: "kdlbs", + repo: "kandev", + issue_number: 1567, + issue_url: ISSUE_URL, + issue_title: "add Download option", + }; + mocks.linkTaskIssue.mockResolvedValue(link); + renderIssueLauncher([repo({ id: LOCAL_REPO_ID })]); - mocks.dialogProps?.onSuccess?.({ id: "task-1" } as Task); + mocks.dialogProps?.onSuccess?.({ id: "task-1" } as Task, "create", { autoFocus }); - expect(mocks.linkTaskIssue).toHaveBeenCalledWith("task-1", { issue: ISSUE_URL }); - await waitFor(() => { - expect(mocks.upsertTaskIssue).toHaveBeenCalledWith(WORKSPACE_ID, link); - }); - expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); - }); + expect(mocks.linkTaskIssue).toHaveBeenCalledWith("task-1", { issue: ISSUE_URL }); + await waitFor(() => { + expect(mocks.upsertTaskIssue).toHaveBeenCalledWith(WORKSPACE_ID, link); + }); + expect(mocks.push).toHaveBeenCalledTimes(autoFocus ? 1 : 0); + if (autoFocus) expect(mocks.push).toHaveBeenCalledWith("/tasks/task-1"); + }, + ); it("navigates when issue linking fails", async () => { mocks.linkTaskIssue.mockRejectedValueOnce(new Error("offline")); diff --git a/apps/web/components/github/my-github/quick-task-launcher.tsx b/apps/web/components/github/my-github/quick-task-launcher.tsx index 7af7fd9b67..98b96ac0f0 100644 --- a/apps/web/components/github/my-github/quick-task-launcher.tsx +++ b/apps/web/components/github/my-github/quick-task-launcher.tsx @@ -206,7 +206,7 @@ export function QuickTaskLauncher({ const handleOpenChange = (open: boolean) => { if (!open) onClose(); }; - const handleSuccess = (task: Task) => { + const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { if (payload?.kind === "pr" && workspaceId) { const repositoryId = pickRepositoryIdForPR( task.repositories, @@ -236,7 +236,7 @@ export function QuickTaskLauncher({ }); } onClose(); - router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/gitlab/my-gitlab/quick-task-launcher.test.tsx b/apps/web/components/gitlab/my-gitlab/quick-task-launcher.test.tsx index fb551ab1e2..a068016dfd 100644 --- a/apps/web/components/gitlab/my-gitlab/quick-task-launcher.test.tsx +++ b/apps/web/components/gitlab/my-gitlab/quick-task-launcher.test.tsx @@ -72,7 +72,7 @@ describe("GitLab QuickTaskLauncher", () => { pushMock.mockReset(); }); - it("prefills the matching GitLab repository and links the created task", async () => { + it.each([true, false])("links the created task with auto-focus %s", async (autoFocus) => { render( @@ -125,10 +125,20 @@ describe("GitLab QuickTaskLauncher", () => { }); await act(async () => { - await (dialogProps?.onSuccess as (task: Task) => Promise)({ - id: "task-1", - repositories: [{ repository_id: "repo-1" }], - } as Task); + await ( + dialogProps?.onSuccess as ( + task: Task, + mode: "create", + meta: { autoFocus: boolean }, + ) => Promise + )( + { + id: "task-1", + repositories: [{ repository_id: "repo-1" }], + } as Task, + "create", + { autoFocus }, + ); }); expect(createTaskMRMock).toHaveBeenCalledWith( { @@ -138,7 +148,8 @@ describe("GitLab QuickTaskLauncher", () => { }, WORKSPACE_ID, ); - expect(pushMock).toHaveBeenCalledWith("/t/task-1"); + expect(pushMock).toHaveBeenCalledTimes(autoFocus ? 1 : 0); + if (autoFocus) expect(pushMock).toHaveBeenCalledWith("/t/task-1"); }); }); diff --git a/apps/web/components/gitlab/my-gitlab/quick-task-launcher.tsx b/apps/web/components/gitlab/my-gitlab/quick-task-launcher.tsx index 3548a74590..0933c11eb6 100644 --- a/apps/web/components/gitlab/my-gitlab/quick-task-launcher.tsx +++ b/apps/web/components/gitlab/my-gitlab/quick-task-launcher.tsx @@ -119,7 +119,11 @@ export function QuickTaskLauncher({ const firstStep = workflowSteps[0]; if (!workspaceId || !workflow || !firstStep || !payload || !initialValues) return null; - const handleSuccess = async (task: Task) => { + const handleSuccess = async ( + task: Task, + _mode?: "create" | "edit", + meta?: { autoFocus?: boolean }, + ) => { if (payload.kind === "mr") { const repositoryID = linkedRepositoryID(task, initialValues.repositoryId); try { @@ -144,7 +148,7 @@ export function QuickTaskLauncher({ } } onClose(); - router.push(linkToTask(task.id)); + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); }; return ( diff --git a/apps/web/components/improve-kandev-dialog-create.tsx b/apps/web/components/improve-kandev-dialog-create.tsx index b821d43711..b4b34db152 100644 --- a/apps/web/components/improve-kandev-dialog-create.tsx +++ b/apps/web/components/improve-kandev-dialog-create.tsx @@ -86,7 +86,7 @@ type CreateModeViewProps = { captureLogs: boolean; setCaptureLogs: (v: boolean) => void; transformDescription: (description: string) => Promise; - onTaskCreated: (task: Task) => void; + onTaskCreated: (task: Task, mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => void; externalBlockedReason?: string | null; }; diff --git a/apps/web/components/improve-kandev-dialog.test.tsx b/apps/web/components/improve-kandev-dialog.test.tsx index f81c01781e..6af0a11c18 100644 --- a/apps/web/components/improve-kandev-dialog.test.tsx +++ b/apps/web/components/improve-kandev-dialog.test.tsx @@ -15,6 +15,9 @@ const WORKSPACE_CHOICE_CONFIRM_TESTID = "improve-kandev-create-workspace-confirm const GH_AUTH_MESSAGE = "Run gh auth login."; const mocks = vi.hoisted(() => ({ + created: null as + | null + | ((task: { id: string }, mode: "create", meta: { autoFocus: boolean }) => void), bootstrap: vi.fn(), listRepositories: vi.fn(), listWorkflowSteps: vi.fn(), @@ -54,15 +57,22 @@ vi.mock("@/lib/api/domains/health-api", () => ({ fetchSystemHealth: mocks.health, })); vi.mock("./improve-kandev-dialog-create", () => ({ - CreateModeView: (props: { workspaceId: string | null; bootstrap: { kind: string } }) => ( -
- create view -
- ), + CreateModeView: (props: { + workspaceId: string | null; + bootstrap: { kind: string }; + onTaskCreated: NonNullable; + }) => { + mocks.created = props.onTaskCreated; + return ( +
+ create view +
+ ); + }, })); const bootstrapResponse: ImproveKandevBootstrapResponse = { @@ -334,3 +344,20 @@ describe("improve-kandev dialog copy", () => { expect(container.textContent).toBe("Contributing as @octocat. You have write access."); }); }); + +it("preserves background creation metadata for Improve navigation callers", async () => { + setStoreWorkspaces([ACTIVE_WORKSPACE, IMPROVE_WORKSPACE]); + window.localStorage.setItem(IMPROVE_KANDEV_SKIP_INTRO_KEY, "true"); + const onSuccess = vi.fn(); + render( + , + ); + await screen.findByTestId("create-mode-view"); + act(() => mocks.created?.({ id: "new-task" }, "create", { autoFocus: false })); + expect(onSuccess).toHaveBeenCalledWith({ id: "new-task" }, { autoFocus: false }); +}); diff --git a/apps/web/components/improve-kandev-dialog.tsx b/apps/web/components/improve-kandev-dialog.tsx index 0b731593f1..3fd5d4d4ca 100644 --- a/apps/web/components/improve-kandev-dialog.tsx +++ b/apps/web/components/improve-kandev-dialog.tsx @@ -36,7 +36,7 @@ type ImproveKandevDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; workspaceId: string | null; - onSuccess?: (task: Task) => void; + onSuccess?: (task: Task, meta?: { autoFocus?: boolean }) => void; }; type Mode = "intro" | "create"; @@ -581,7 +581,7 @@ function ImproveKandevCreateStage({ bootstrap: BootstrapState; captureLogs: boolean; setCaptureLogs: (capture: boolean) => void; - onSuccess?: (task: Task) => void; + onSuccess?: (task: Task, meta?: { autoFocus?: boolean }) => void; externalBlockedReason: string | null; improveWorkspaceMissing: boolean; workspaceChoiceConfirmed: boolean; @@ -591,9 +591,9 @@ function ImproveKandevCreateStage({ onCancel: () => void; }) { const handleSuccess = useCallback( - (task: Task) => { + (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { onOpenChange(false); - onSuccess?.(task); + onSuccess?.(task, meta); }, [onOpenChange, onSuccess], ); diff --git a/apps/web/components/jira/my-jira/quick-task-launcher.tsx b/apps/web/components/jira/my-jira/quick-task-launcher.tsx index d3d69c8139..0495cb4471 100644 --- a/apps/web/components/jira/my-jira/quick-task-launcher.tsx +++ b/apps/web/components/jira/my-jira/quick-task-launcher.tsx @@ -60,9 +60,9 @@ export function QuickTaskLauncher({ const handleOpenChange = (open: boolean) => { if (!open) onClose(); }; - const handleSuccess = (task: Task) => { + const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { onClose(); - router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/linear/linear-quick-task-launcher.tsx b/apps/web/components/linear/linear-quick-task-launcher.tsx index 7ba146356a..73dadb6069 100644 --- a/apps/web/components/linear/linear-quick-task-launcher.tsx +++ b/apps/web/components/linear/linear-quick-task-launcher.tsx @@ -82,9 +82,9 @@ export function LinearQuickTaskLauncher({ const handleOpenChange = (open: boolean) => { if (!open) onClose(); }; - const handleSuccess = (task: Task) => { + const handleSuccess = (task: Task, _mode?: "create" | "edit", meta?: { autoFocus?: boolean }) => { onClose(); - router.push(`/tasks/${task.id}`); + if (meta?.autoFocus !== false) router.push(`/tasks/${task.id}`); }; if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; diff --git a/apps/web/components/navigation/app-nav-sections.tsx b/apps/web/components/navigation/app-nav-sections.tsx index 1fc5f7e44e..6a54dcf50b 100644 --- a/apps/web/components/navigation/app-nav-sections.tsx +++ b/apps/web/components/navigation/app-nav-sections.tsx @@ -74,7 +74,9 @@ export function useAppNavDialogs(closeMenu: () => void): AppNavDialogControls { open onOpenChange={setImproveOpen} workspaceId={workspaceId} - onSuccess={(task) => router.push(linkToTask(task.id))} + onSuccess={(task, meta) => { + if (meta?.autoFocus !== false) router.push(linkToTask(task.id)); + }} /> )} {health.dialogOpen && ( diff --git a/apps/web/components/settings/creation-auto-focus-settings.test.tsx b/apps/web/components/settings/creation-auto-focus-settings.test.tsx new file mode 100644 index 0000000000..491d284f2a --- /dev/null +++ b/apps/web/components/settings/creation-auto-focus-settings.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StateProvider, useAppStore } from "@/components/state-provider"; +import { defaultState } from "@/lib/state/default-state"; +import { SettingsSaveProvider } from "./settings-save-provider"; + +const updateUserSettings = vi.fn(); +const TOGGLE_LABEL = "Auto-focus new tasks"; +const SAVE_LABEL = "Save changes"; + +vi.mock("@/lib/api", () => ({ + updateUserSettings: (...args: unknown[]) => updateUserSettings(...args), +})); + +import { CreationAutoFocusSettings } from "./creation-auto-focus-settings"; + +function EffectivePreference() { + const enabled = useAppStore((state) => state.userSettings.autoFocusNewTasks); + return {String(enabled)}; +} + +function renderSettings(autoFocusNewTasks = true) { + return render( + + + + + + , + ); +} + +const DATA_STATE_ATTRIBUTE = "data-state"; +const CHECKED_STATE = "checked"; +const UNCHECKED_STATE = "unchecked"; + +beforeEach(() => { + updateUserSettings.mockReset().mockResolvedValue({ settings: {} }); +}); + +afterEach(cleanup); + +describe("CreationAutoFocusSettings", () => { + it("keeps an explicit true value local until Save changes is pressed", async () => { + renderSettings(true); + + const toggle = screen.getByRole("switch", { name: TOGGLE_LABEL }); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + expect(updateUserSettings).not.toHaveBeenCalled(); + + fireEvent.click(toggle); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(UNCHECKED_STATE); + + fireEvent.click(screen.getByRole("button", { name: SAVE_LABEL })); + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + auto_focus_new_tasks: false, + }); + }); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(UNCHECKED_STATE); + }); + + it("persists enabling the preference", async () => { + renderSettings(false); + + const toggle = screen.getByRole("switch", { name: TOGGLE_LABEL }); + fireEvent.click(toggle); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + + fireEvent.click(screen.getByRole("button", { name: SAVE_LABEL })); + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + auto_focus_new_tasks: true, + }); + }); + }); + + it("registers its own save contributor so the archive card keeps working", async () => { + renderSettings(false); + fireEvent.click(screen.getByRole("switch", { name: TOGGLE_LABEL })); + fireEvent.click(screen.getByRole("button", { name: SAVE_LABEL })); + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + auto_focus_new_tasks: true, + }); + }); + }); +}); + +it("discards an unsaved change without altering effective behavior", async () => { + renderSettings(); + fireEvent.click(screen.getByRole("switch", { name: TOGGLE_LABEL })); + expect(screen.getByTestId("effective-auto-focus").textContent).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + await waitFor(() => + expect(screen.getByRole("switch").getAttribute("data-state")).toBe("checked"), + ); + expect(updateUserSettings).not.toHaveBeenCalled(); +}); + +it("keeps the saved value effective after a failed save and allows retry", async () => { + updateUserSettings.mockRejectedValueOnce(new Error("save failed")); + renderSettings(); + fireEvent.click(screen.getByRole("switch", { name: TOGGLE_LABEL })); + fireEvent.click(screen.getByRole("button", { name: SAVE_LABEL })); + await screen.findByRole("button", { name: "Retry save" }); + expect(screen.getByTestId("effective-auto-focus").textContent).toBe("true"); + expect(screen.getByRole("switch").getAttribute("data-settings-dirty")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + await waitFor(() => expect(screen.getByTestId("effective-auto-focus").textContent).toBe("false")); +}); diff --git a/apps/web/components/settings/creation-auto-focus-settings.tsx b/apps/web/components/settings/creation-auto-focus-settings.tsx new file mode 100644 index 0000000000..bdd51c2601 --- /dev/null +++ b/apps/web/components/settings/creation-auto-focus-settings.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Label } from "@kandev/ui/label"; +import { Switch } from "@kandev/ui/switch"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { updateUserSettings } from "@/lib/api"; +import { SettingsCard } from "./settings-card"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; +import { useSettingsSaveContributor } from "./settings-save-provider"; +import { useTranslation } from "react-i18next"; + +export function CreationAutoFocusSettings() { + const { t } = useTranslation(); + const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const storeApi = useAppStoreApi(); + const [saved, setSaved] = useState(autoFocusNewTasks); + const [draft, setDraft] = useState(autoFocusNewTasks); + const draftRef = useRef(draft); + draftRef.current = draft; + const isDirty = draft !== saved; + + useEffect(() => { + setSaved((previous) => { + if (draftRef.current === previous) setDraft(autoFocusNewTasks); + return autoFocusNewTasks; + }); + }, [autoFocusNewTasks]); + + useSettingsSaveContributor({ + id: "general-creation-auto-focus", + revision: Number(draft), + isDirty, + save: async (revision) => { + const submitted = Boolean(revision); + await updateUserSettings({ auto_focus_new_tasks: submitted }); + setSaved(submitted); + setUserSettings({ + ...storeApi.getState().userSettings, + autoFocusNewTasks: submitted, + }); + }, + discard: () => setDraft(saved), + }); + + return ( + + + {t("settings:autoFocusNewTasks")} + + +
+
+ +

{t("settings:autoFocusNewTasksHelp")}

+
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 7d852cabd0..2957966a71 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -27,6 +27,7 @@ import { updateUserSettings } from "@/lib/api"; import type { Theme } from "@/lib/settings/types"; import type { UserSettingsState } from "@/lib/state/slices/settings/types"; import { ArchiveConfirmationSettings } from "@/components/settings/archive-confirmation-settings"; +import { CreationAutoFocusSettings } from "@/components/settings/creation-auto-focus-settings"; import { PreventAutoStartAgentSettings } from "@/components/settings/prevent-auto-start-agent-settings"; import { LanguageSettings } from "@/components/settings/language-settings"; import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings"; @@ -233,6 +234,7 @@ export function TaskActionsSettings() { >
+ diff --git a/apps/web/components/task-create-dialog-helpers.test.ts b/apps/web/components/task-create-dialog-helpers.test.ts index 9b6f50223a..3acb0b88e3 100644 --- a/apps/web/components/task-create-dialog-helpers.test.ts +++ b/apps/web/components/task-create-dialog-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { + activatePlanMode, autoSelectBranch, buildCreateTaskPayload, buildRepositoriesPayload, @@ -9,6 +10,8 @@ import { shouldShowTaskTitleField, validateCreateInputs, } from "./task-create-dialog-helpers"; +import { createAppStore } from "@/lib/state/store"; +import { useContextFilesStore } from "@/lib/state/context-files-store"; import type { TaskRemoteRepoRow } from "./task-create-dialog-types"; const STORAGE_KEYS = { LAST_BRANCH: "kandev.dialog.lastBranch" } as const; @@ -26,6 +29,8 @@ const AGENT_PAYLOAD_DEFAULTS = { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); + useContextFilesStore.setState({ filesBySessionId: {} }); }); describe("autoSelectBranch", () => { @@ -331,3 +336,49 @@ describe("buildCreateTaskPayload priority", () => { expect(buildCreateTaskPayload(base).priority).toBe("medium"); }); }); + +it("initializes a planning session without navigating when auto-focus is off", () => { + const currentSessionId = "current-session"; + const backgroundSessionId = "session-plan"; + const store = createAppStore(); + const { setActiveDocument, setPlanMode, setActiveSession } = store.getState(); + setActiveSession("current-task", currentSessionId); + setActiveDocument(currentSessionId, { type: "plan", taskId: "current-task" }); + setPlanMode(currentSessionId, false); + useContextFilesStore.getState().addFile(currentSessionId, { path: "notes.md", name: "notes.md" }); + const currentTasks = store.getState().tasks; + const currentDocument = + store.getState().documentPanel.activeDocumentBySessionId[currentSessionId]; + const currentContext = useContextFilesStore.getState().filesBySessionId[currentSessionId]; + const router = { + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), + }; + activatePlanMode({ + taskId: "task-plan", + sessionId: backgroundSessionId, + autoFocus: false, + setActiveDocument, + setPlanMode, + router, + }); + expect(store.getState().chatInput.planModeBySessionId[backgroundSessionId]).toBe(true); + expect(store.getState().documentPanel.activeDocumentBySessionId[backgroundSessionId]).toEqual({ + type: "plan", + taskId: "task-plan", + }); + expect(useContextFilesStore.getState().filesBySessionId[backgroundSessionId]).toEqual([ + { path: "plan:context", name: "Plan" }, + ]); + expect(store.getState().tasks).toBe(currentTasks); + expect(store.getState().documentPanel.activeDocumentBySessionId[currentSessionId]).toBe( + currentDocument, + ); + expect(store.getState().chatInput.planModeBySessionId[currentSessionId]).toBe(false); + expect(useContextFilesStore.getState().filesBySessionId[currentSessionId]).toBe(currentContext); + expect(router.push).not.toHaveBeenCalled(); +}); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 0691791399..7a6d3b0607 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -172,6 +172,7 @@ export function shouldShowTaskTitleField( } export type ActivatePlanModeArgs = { + autoFocus?: boolean; sessionId: string; taskId: string; setActiveDocument: AppState["setActiveDocument"]; @@ -180,6 +181,7 @@ export type ActivatePlanModeArgs = { }; export function activatePlanMode({ + autoFocus = true, sessionId, taskId, setActiveDocument, @@ -189,7 +191,7 @@ export function activatePlanMode({ setActiveDocument(sessionId, { type: "plan", taskId }); setPlanMode(sessionId, true); useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); - router.push(linkToTask(taskId, INTENT_PLAN)); + if (autoFocus) router.push(linkToTask(taskId, INTENT_PLAN)); } export type BuildCreatePayloadArgs = { diff --git a/apps/web/components/task-create-dialog-submit.test.tsx b/apps/web/components/task-create-dialog-submit.test.tsx index d6f2908f72..11e0c8422d 100644 --- a/apps/web/components/task-create-dialog-submit.test.tsx +++ b/apps/web/components/task-create-dialog-submit.test.tsx @@ -12,6 +12,7 @@ import { WebSocketRequestError } from "@/lib/ws/client"; // assert that handleCreateSubmit honours CLI-mode parity: empty prompt → no // create call; non-empty prompt → call with that prompt in the payload. +let autoFocusNewTasks = true; const pushMock = vi.fn(); const TASK_ID = "task-1"; const RENAMED_TITLE = "Renamed task"; @@ -33,6 +34,7 @@ vi.mock("@/components/toast-provider", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: unknown) => unknown) => selector({ + userSettings: { autoFocusNewTasks }, setActiveDocument: vi.fn(), setPlanMode: vi.fn(), applyAgentProfileRecentUse: vi.fn(), @@ -203,6 +205,7 @@ function makeDeps(overrides: Partial): SubmitHandlersDeps { } beforeEach(() => { + autoFocusNewTasks = true; resetTaskCreateLastUsedSync({ clearQueued: true }); buildCreateTaskPayloadMock.mockClear(); buildRepositoriesPayloadMock.mockReset(); @@ -989,3 +992,48 @@ describe("taskSubmitErrorMessage", () => { expect(taskSubmitErrorMessage(new Error("ordinary failure"))).toBe("ordinary failure"); }); }); + +// @covers AC-TASKS-CREATION-AUTO-FOCUS-001.2, AC-TASKS-CREATION-AUTO-FOCUS-001.3 +describe("creation auto-focus policy", () => { + it.each([false, true])( + "keeps passthrough navigation gated by %s while completing creation", + async (enabled) => { + autoFocusNewTasks = enabled; + const deps = makeDeps({ + isPassthroughProfile: true, + descriptionInputRef: makeRef("Start this task"), + createTask: vi.fn().mockResolvedValue({ id: TASK_ID, session_id: "new-session" }), + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + await act(async () => { + await result.current.handleSubmit({ preventDefault() {} } as never); + }); + expect(deps.onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ id: TASK_ID }), + "create", + expect.objectContaining({ autoFocus: enabled, willNavigate: enabled }), + ); + expect(deps.onOpenChange).toHaveBeenCalledWith(false); + expect(deps.clearDraft).toHaveBeenCalled(); + expect(pushMock).toHaveBeenCalledTimes(enabled ? 1 : 0); + }, + ); + it("propagates disabled focus for creation without an agent", async () => { + autoFocusNewTasks = false; + const deps = makeDeps({ + descriptionInputRef: makeRef("Create only"), + createTask: vi.fn().mockResolvedValue({ id: TASK_ID }), + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + await act(async () => { + await result.current.handleCreateWithoutAgent(); + }); + expect(deps.onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ id: TASK_ID }), + "create", + expect.objectContaining({ autoFocus: false }), + ); + expect(deps.onOpenChange).toHaveBeenCalledWith(false); + expect(pushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index 6bae885e30..16eee3b916 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -331,6 +331,7 @@ export function useTaskSubmitHandlers({ transformDescriptionBeforeSubmit, }: SubmitHandlersDeps) { const router = useRouter(); + const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks) !== false; const { toast } = useToast(); const setActiveDocument = useAppStore((state) => state.setActiveDocument); const setPlanMode = useAppStore((state) => state.setPlanMode); @@ -743,13 +744,19 @@ export function useTaskSubmitHandlers({ if (!taskResponse) return; notifyQueuedTask(taskResponse, toast); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; - const willNavigate = shouldNavigateAfterTaskCreate( - opts.withAgent, - isPassthroughProfile, - opts.planMode, - newSessionId, - ); - onSuccess?.(taskResponse, "create", { taskSessionId: newSessionId, willNavigate }); + const willNavigate = + autoFocusNewTasks && + shouldNavigateAfterTaskCreate( + opts.withAgent, + isPassthroughProfile, + opts.planMode, + newSessionId, + ); + onSuccess?.(taskResponse, "create", { + taskSessionId: newSessionId, + willNavigate, + autoFocus: autoFocusNewTasks, + }); clearDraft(); queueTaskCreateLastUsedFromPayload(submittedPayload); preserveTaskCreateLastUsedOnClose?.(); @@ -758,17 +765,19 @@ export function useTaskSubmitHandlers({ activatePlanMode({ sessionId: newSessionId, taskId: taskResponse.id, + autoFocus: autoFocusNewTasks, setActiveDocument, setPlanMode, router, }); - } else if (opts.withAgent && isPassthroughProfile) { + } else if (autoFocusNewTasks && opts.withAgent && isPassthroughProfile) { router.push(linkToTask(taskResponse.id)); } }, [ workspaceId, effectiveWorkflowId, + autoFocusNewTasks, autoTitle, blockedBy, agentProfileId, @@ -1027,7 +1036,7 @@ export function useTaskSubmitHandlers({ const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, consent); if (!taskResponse) return; notifyQueuedTask(taskResponse, toast); - onSuccess?.(taskResponse, "create"); + onSuccess?.(taskResponse, "create", { autoFocus: autoFocusNewTasks }); clearDraft(); queueTaskCreateLastUsedFromPayload(submittedPayload); preserveTaskCreateLastUsedOnClose?.(); @@ -1060,6 +1069,7 @@ export function useTaskSubmitHandlers({ ensureFreshBranchConsent, createTaskWithFreshBranchRetry, refreshStaleBranchPolicies, + autoFocusNewTasks, onSuccess, onOpenChange, preserveTaskCreateLastUsedOnClose, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 1b71cd89a3..661d8033b9 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -75,7 +75,7 @@ export interface TaskCreateDialogProps { onSuccess?: ( task: Task, mode: "create" | "edit", - meta?: { taskSessionId?: string | null; willNavigate?: boolean }, + meta?: { taskSessionId?: string | null; willNavigate?: boolean; autoFocus?: boolean }, ) => void; onCreateSession?: (data: { prompt: string; @@ -548,7 +548,7 @@ export type SubmitHandlersDeps = { onSuccess?: ( task: Task, mode: "create" | "edit", - meta?: { taskSessionId?: string | null; willNavigate?: boolean }, + meta?: { taskSessionId?: string | null; willNavigate?: boolean; autoFocus?: boolean }, ) => void; onCreateSession?: (data: { prompt: string; diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index 7a3ea86087..30bf277bfb 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -28,6 +28,7 @@ type CloseAutoFocusEvent = { preventDefault: () => void }; let allowProgrammaticSet = true; let mockFs: DialogFormState; let dialogEscapeHandler: ((event: EscapeEvent) => void) | undefined; +let autoFocusNewTasks = true; let dialogCloseAutoFocusHandler: ((event: CloseAutoFocusEvent) => void) | undefined; vi.mock("@kandev/ui/dialog", () => ({ @@ -128,7 +129,7 @@ vi.mock("@/components/state-provider", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any useAppStore: (selector: (state: any) => unknown) => selector({ - userSettings: { taskCreateLastUsed: null }, + userSettings: { taskCreateLastUsed: null, autoFocusNewTasks }, repositorySets: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, @@ -395,6 +396,7 @@ beforeEach(() => { setHasDescriptionMock.mockReset(); dialogEscapeHandler = undefined; dialogCloseAutoFocusHandler = undefined; + autoFocusNewTasks = true; mockFs = buildMockFs(); }); @@ -554,3 +556,20 @@ describe("TaskCreateDialog prompt enhancement", () => { expect(screen.getByTestId(PROMPT_RESULT_RECOVERY_TEST_ID)).toBeTruthy(); }); }); + +it("returns to the opening control after background task creation", () => { + autoFocusNewTasks = false; + const target = document.createElement("button"); + document.body.appendChild(target); + try { + target.focus(); + renderDialog(); + target.blur(); + const event = { preventDefault: vi.fn() }; + dialogCloseAutoFocusHandler?.(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(document.activeElement).toBe(target); + } finally { + target.remove(); + } +}); diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 1879864096..54fe59d7d6 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -223,6 +223,33 @@ function DialogFormBody(props: DialogFormBodyProps) { // `preventDefault` off the event, so a stubbed shape is sufficient. const PROGRAMMATIC_SUBMIT_EVENT = { preventDefault: () => {} } as unknown as FormEvent; +function useTaskCreateFocusReturn(props: TaskCreateDialogProps, isCreateMode: boolean) { + const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks) !== false; + const activeElement = () => + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const [openingFocus, setOpeningFocus] = useState(() => ({ + open: props.open, + target: activeElement(), + })); + // Capture the opener before descendants run their input-focus effects. + if (openingFocus.open !== props.open) { + setOpeningFocus({ + open: props.open, + target: props.open ? activeElement() : openingFocus.target, + }); + } + return { + onCloseAutoFocus: (event: Event) => { + const target = + props.focusReturnRef?.current ?? + (isCreateMode && !autoFocusNewTasks ? openingFocus.target : null); + if (!target || !document.contains(target)) return; + event.preventDefault(); + target.focus(); + }, + }; +} + export function TaskCreateDialog(props: TaskCreateDialogProps) { const { t } = useTranslation("chat"); const syncedTaskCreateLastUsed = useAppStore((state) => state.userSettings.taskCreateLastUsed); @@ -244,6 +271,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { }, []); const setup = useTaskCreateDialogSetup(props, { preserveQueuedLastUsedOnClose }); const { guardedHandleSubmit } = setup; + const focusReturn = useTaskCreateFocusReturn(props, setup.isCreateMode); const [popoverContainer, setPopoverContainer] = useState(null); useEffect(() => { if (props.open) { @@ -277,12 +305,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { onEscapeKeyDown={(event) => { if (setup.isCreateMode) event.preventDefault(); }} - onCloseAutoFocus={(event) => { - const target = props.focusReturnRef?.current; - if (!target || !document.contains(target)) return; - event.preventDefault(); - target.focus(); - }} + {...focusReturn} data-testid="create-task-dialog" data-webkit-safe-motion="true" showCloseButton={false} diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index b382681868..ab0c2c4177 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -313,7 +313,12 @@ function SidebarRightActions() { ); const handleTaskCreated = useCallback( - (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { + ( + task: Task, + _mode: "create" | "edit", + meta?: { taskSessionId?: string | null; autoFocus?: boolean }, + ) => { + if (meta?.autoFocus === false) return; const state = appStore.getState(); const oldSessionId = state.tasks.activeSessionId; const oldEnvId = oldSessionId ? (state.environmentIdBySessionId[oldSessionId] ?? null) : null; diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-dialogs.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet-dialogs.tsx index e62f828b93..0d3db27378 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-dialogs.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-dialogs.tsx @@ -1,3 +1,4 @@ +import type { RefObject } from "react"; import { NewSubtaskDialog } from "../new-subtask-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog"; @@ -30,6 +31,7 @@ export function MobileSubtaskDialog({ } export function TaskSwitcherDialogs({ + focusReturnRef, dialogOpen, onDialogOpenChange, workspaceId, @@ -42,6 +44,7 @@ export function TaskSwitcherDialogs({ subtaskTarget, onSubtaskTargetChange, }: { + focusReturnRef?: RefObject; dialogOpen: boolean; onDialogOpenChange: (open: boolean) => void; workspaceId: string | null; @@ -57,6 +60,7 @@ export function TaskSwitcherDialogs({ return ( <> { + ( + task: Task, + _mode: "create" | "edit", + meta?: { taskSessionId?: string | null; autoFocus?: boolean }, + ) => { store.setState((state) => { if (state.kanban.workflowId !== task.workflow_id) return state; const existing = state.kanban.tasks.find( @@ -472,6 +476,10 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { }, }; }); + if (meta?.autoFocus === false) { + onOpenChange(false); + return; + } setActiveTask(task.id); if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index f1e92ea2f5..6c3bcb901b 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -45,6 +45,20 @@ type SessionTaskSwitcherSheetProps = { navigate?: (taskId: string) => void; onCloseAutoFocus?: (event: Event) => void; }; +function useTaskSheetOpener(open: boolean) { + const [opener, setOpener] = useState({ open: false, current: null as HTMLElement | null }); + if (opener.open !== open) { + setOpener({ + open, + current: + open && document.activeElement instanceof HTMLElement + ? document.activeElement + : opener.current, + }); + } + return opener; +} + export function useTaskSheetSelectionController() { const [selectionController] = useState(createTaskSheetSelectionController); useEffect( @@ -445,6 +459,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ onCloseAutoFocus, }: SessionTaskSwitcherSheetProps) { const [dialogOpen, setDialogOpen] = useState(false); + const opener = useTaskSheetOpener(open); + const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks) !== false; const [subtaskTarget, setSubtaskTarget] = useState<{ id: string; title: string } | null>(null); const data = useSheetData(workspaceId); const selectionController = useTaskSheetSelectionController(); @@ -515,6 +531,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ <> {surface} void; }; diff --git a/apps/web/components/task/new-task-dropdown.tsx b/apps/web/components/task/new-task-dropdown.tsx index 6f2d3dd40f..5d58a0c2e2 100644 --- a/apps/web/components/task/new-task-dropdown.tsx +++ b/apps/web/components/task/new-task-dropdown.tsx @@ -24,7 +24,7 @@ type NewTaskDropdownProps = { onTaskCreated: ( task: Task, mode: "create" | "edit", - meta?: { taskSessionId?: string | null }, + meta?: { taskSessionId?: string | null; autoFocus?: boolean }, ) => void; }; diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index a9575caf7d..60e2a848b0 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1226,6 +1226,7 @@ export class ApiClient { enable_preview_on_click?: boolean; confirm_task_archive?: boolean; prevent_auto_start_agent_on_open?: boolean; + auto_focus_new_tasks?: boolean; unread_divider?: boolean; agent_generated_task_titles?: boolean; mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; diff --git a/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts new file mode 100644 index 0000000000..1ff2749a3f --- /dev/null +++ b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts @@ -0,0 +1,157 @@ +import { expect, type Page } from "@playwright/test"; +import type { ApiClient } from "../../helpers/api-client"; +import { KanbanPage } from "../../pages/kanban-page"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import { SessionPage } from "../../pages/session-page"; +import { waitForHttp } from "../../helpers/causal-waits"; + +async function saveFocusPreference(page: Page, enabled: boolean, mobile: boolean) { + await page.goto("/settings/preferences/task-behavior"); + const card = page.getByTestId("creation-auto-focus-card"); + const toggle = card.getByRole("switch", { name: "Auto-focus new tasks" }); + await expect(toggle).toBeChecked({ checked: !enabled }); + await toggle.scrollIntoViewIfNeeded(); + if (mobile) { + const box = await toggle.boundingBox(); + expect(box!.width).toBeGreaterThan(box!.height * 1.5); + const touchArea = await toggle.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const after = getComputedStyle(element, "::after"); + return { + height: bounds.height - parseFloat(after.top) - parseFloat(after.bottom), + width: bounds.width - parseFloat(after.left) - parseFloat(after.right), + }; + }); + expect(touchArea.height).toBeGreaterThanOrEqual(44); + expect(touchArea.width).toBeGreaterThanOrEqual(44); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( + await page.evaluate(() => document.documentElement.clientWidth), + ); + } + if (mobile) { + const box = (await toggle.boundingBox())!; + // Tap the expanded target above the visible track. + await page.touchscreen.tap(box.x + box.width / 2, box.y - 10); + } else { + await toggle.click(); + } + await expect(toggle).toBeChecked({ checked: enabled }); + await page + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Save changes" }) + .click(); + await expect(page.getByTestId("settings-floating-save")).toBeHidden(); + await page.reload(); + await expect(toggle).toBeChecked({ checked: enabled }); +} + +async function submitTask(page: Page, title: string, withAgent: boolean, mobile: boolean) { + const dialog = page.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("task-title-input").fill(title); + await dialog.getByTestId("task-description-input").fill("/e2e:simple-message"); + if (!withAgent && !mobile) { + await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled(); + await dialog.getByTestId("submit-start-agent-chevron").click(); + } + const submit = + !withAgent && mobile + ? dialog.getByRole("button", { name: "Create only", exact: true }) + : page.getByTestId(withAgent ? "submit-start-agent" : "submit-create-without-agent"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(dialog).toBeHidden(); +} + +async function openCreationDialog(page: Page, open: () => Promise) { + // Local repository status supplies the default branch used by submit eligibility. + const repositoryReady = waitForHttp(page, "GET", /\/repositories\/local-status$/); + await open(); + const response = await repositoryReady; + expect(response.ok()).toBe(true); + await response.finished(); +} + +async function openFromTask(page: Page, mobile: boolean) { + if (mobile) { + await page.getByTestId("mobile-session-menu").click(); + await page + .getByRole("dialog", { name: "Tasks", exact: true }) + .getByRole("button", { name: "New", exact: true }) + .click(); + } else { + await page.getByTestId("create-task-button").click(); + } +} + +async function taskID(api: ApiClient, workspaceID: string, title: string) { + let id: string | undefined; + await expect + .poll(async () => { + id = (await api.listTasks(workspaceID)).tasks.find((task) => task.title === title)?.id; + return id; + }) + .toBeTruthy(); + return id!; +} + +// @covers AC-TASKS-CREATION-AUTO-FOCUS-001.1 through .5 +export async function verifyCreationAutoFocus( + page: Page, + api: ApiClient, + workspaceID: string, + mobile: boolean, +) { + const { settings: baseline } = await api.getUserSettings(); + expect(baseline.auto_focus_new_tasks).toBe(true); + await api.saveUserSettings({ agent_generated_task_titles: false }); + try { + await saveFocusPreference(page, false, mobile); + const board = mobile ? new MobileKanbanPage(page) : new KanbanPage(page); + await board.goto(); + const listingURL = page.url(); + const opener = mobile + ? (board as MobileKanbanPage).mobileFab + : page.getByTestId("create-task-button"); + await openCreationDialog(page, () => opener.click()); + await submitTask(page, "Background task one", false, mobile); + await expect(page).toHaveURL(listingURL); + await expect(opener).toBeFocused(); + const firstID = await taskID(api, workspaceID, "Background task one"); + // The task is discoverable and can still be opened deliberately. + if (mobile) { + await (board as MobileKanbanPage).taskCard(firstID).click(); + } else { + await new SessionPage(page).sidebarTaskItem("Background task one").click(); + } + await expect(page).toHaveURL(new RegExp(`/t/${firstID}`)); + await new SessionPage(page).waitForLoad(); + const activeURL = page.url(); + await openCreationDialog(page, () => openFromTask(page, mobile)); + await submitTask(page, "Background task with agent", true, mobile); + await expect(page).toHaveURL(activeURL); + if (mobile) await expect(page.getByTestId("mobile-session-menu")).toBeFocused(); + const secondID = await taskID(api, workspaceID, "Background task with agent"); + await expect + .poll( + async () => + (await api.listTaskSessions(secondID)).sessions.some((session) => + ["RUNNING", "WAITING_FOR_INPUT", "COMPLETED"].includes(session.state), + ), + { timeout: 30_000 }, + ) + .toBe(true); + await saveFocusPreference(page, true, mobile); + await page.goto(activeURL); + await new SessionPage(page).waitForLoad(); + await openCreationDialog(page, () => openFromTask(page, mobile)); + await submitTask(page, "Focused task again", false, mobile); + const focusedID = await taskID(api, workspaceID, "Focused task again"); + await expect(page).toHaveURL(new RegExp(`/t/${focusedID}`)); + } finally { + await api.saveUserSettings({ + auto_focus_new_tasks: baseline.auto_focus_new_tasks as boolean, + agent_generated_task_titles: baseline.agent_generated_task_titles ?? true, + }); + } +} diff --git a/apps/web/e2e/tests/task/creation-auto-focus.spec.ts b/apps/web/e2e/tests/task/creation-auto-focus.spec.ts new file mode 100644 index 0000000000..be286c4f6c --- /dev/null +++ b/apps/web/e2e/tests/task/creation-auto-focus.spec.ts @@ -0,0 +1,13 @@ +import { test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { verifyCreationAutoFocus } from "./creation-auto-focus-helpers"; + +useRegularMode(); +test.setTimeout(180_000); +test("saves auto-focus and preserves listing and task context during creation", async ({ + testPage, + apiClient, + seedData, +}) => { + await verifyCreationAutoFocus(testPage, apiClient, seedData.workspaceId, false); +}); diff --git a/apps/web/e2e/tests/task/mobile-creation-auto-focus.spec.ts b/apps/web/e2e/tests/task/mobile-creation-auto-focus.spec.ts new file mode 100644 index 0000000000..44051ccde3 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-creation-auto-focus.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { verifyCreationAutoFocus } from "./creation-auto-focus-helpers"; + +useRegularMode(); +test.setTimeout(180_000); +test("keeps mobile creation in context with a saved touch-accessible preference", async ({ + testPage, + apiClient, + seedData, +}, testInfo) => { + expect(testPage.viewportSize()).toEqual(testInfo.project.use.viewport); + expect(await testPage.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0); + expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true); + await verifyCreationAutoFocus(testPage, apiClient, seedData.workspaceId, true); +}); diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 2254747d6a..0ac6da40dc 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -70,6 +70,7 @@ function makeUnloadedSettings(): UserSettingsState { preventAutoStartAgentOnOpen: false, unreadDivider: true, agentGeneratedTaskTitles: false, + autoFocusNewTasks: true, mcpTaskAgentProfileDefault: "current_task", showAnchoredPromptBar: false, showScrollToLastPrompt: true, diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts index 0dbaef8b3d..e9663d090f 100644 --- a/apps/web/lib/settings-discovery/catalog/preferences.ts +++ b/apps/web/lib/settings-discovery/catalog/preferences.ts @@ -35,6 +35,7 @@ export const GENERAL_SETTINGS_TARGETS = { agentTaskProfile: "setting-agent-task-profile", agentGeneratedTitles: "setting-agent-generated-task-titles", archiveConfirmation: "setting-archive-confirmation", + creationAutoFocus: "setting-creation-auto-focus", preventAutoStartOnOpen: "setting-prevent-auto-start-on-open", unreadMessages: "setting-unread-messages", transcriptNavigation: "setting-transcript-navigation", @@ -340,6 +341,16 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = targetId: GENERAL_SETTINGS_TARGETS.preventAutoStartOnOpen, order: 63, }, + { + id: "task-actions-creation-auto-focus", + kind: "control", + labelKey: "settings:autoFocusNewTasks", + parentId: TASK_BEHAVIOR_ID, + groupId: "preferences", + href: TASK_BEHAVIOR_SETTINGS_HREF, + targetId: GENERAL_SETTINGS_TARGETS.creationAutoFocus, + order: 63.5, + }, { id: "task-actions-archive-confirmation", kind: "control", diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json index 71db5f6a6e..0f6f96ec7e 100644 --- a/apps/web/lib/settings-discovery/contract.generated.json +++ b/apps/web/lib/settings-discovery/contract.generated.json @@ -4564,6 +4564,21 @@ "change_timing": "future_views", "settings_href": "/settings/preferences" }, + { + "key": "user_settings.auto_focus_new_tasks", + "field_path": "auto_focus_new_tasks", + "label": "auto focus new tasks", + "description": "Portable preference owned by the current user.", + "json_type": "boolean", + "support": "supported", + "classification": "writable", + "owner": "user-settings", + "writable": true, + "validator": "user-settings", + "authority": "user.self", + "change_timing": "future_views", + "settings_href": "/settings/preferences" + }, { "key": "user_settings.mcp_task_agent_profile_default", "field_path": "mcp_task_agent_profile_default", @@ -6149,6 +6164,7 @@ "agent_generated_task_titles", "app_status_bar_enabled", "app_status_bar_order", + "auto_focus_new_tasks", "azure_devops_browse_preferences", "changes_panel_layout", "chat_submit_key", diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json index 69154792df..0f2f3e2736 100644 --- a/apps/web/lib/settings-discovery/profile-contract.generated.json +++ b/apps/web/lib/settings-discovery/profile-contract.generated.json @@ -323,6 +323,7 @@ "agent_generated_task_titles", "app_status_bar_enabled", "app_status_bar_order", + "auto_focus_new_tasks", "azure_devops_browse_preferences", "changes_panel_layout", "chat_submit_key", diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index a428fa5b51..9a9a866590 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -758,3 +758,17 @@ describe("prevent auto-start on open preference", () => { ).toBe(false); }); }); + +// @covers AC-TASKS-CREATION-AUTO-FOCUS-001.1, AC-TASKS-CREATION-AUTO-FOCUS-001.4 +describe("task creation auto-focus preference", () => { + it("defaults on and preserves explicit false across partial hydration", () => { + expect(createDefaultUserSettings()).toHaveProperty("autoFocusNewTasks", true); + expect(buildCoreFields({ auto_focus_new_tasks: false })).toHaveProperty( + "autoFocusNewTasks", + false, + ); + expect( + buildCoreFields({}, { ...createDefaultUserSettings(), autoFocusNewTasks: false }), + ).toHaveProperty("autoFocusNewTasks", false); + }); +}); diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 313f5aa834..9291f91712 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -51,6 +51,7 @@ export function createDefaultUserSettings(): UserSettingsState { preventAutoStartAgentOnOpen: false, unreadDivider: false, agentGeneratedTaskTitles: true, + autoFocusNewTasks: true, mcpTaskAgentProfileDefault: "current_task", showAnchoredPromptBar: false, showScrollToLastPrompt: true, @@ -272,6 +273,7 @@ function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) { s.prevent_auto_start_agent_on_open ?? current.preventAutoStartAgentOnOpen, unreadDivider: s.unread_divider ?? current.unreadDivider, agentGeneratedTaskTitles: s.agent_generated_task_titles ?? current.agentGeneratedTaskTitles, + autoFocusNewTasks: s.auto_focus_new_tasks ?? current.autoFocusNewTasks, mcpTaskAgentProfileDefault: mapDefined( s.mcp_task_agent_profile_default, current.mcpTaskAgentProfileDefault, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index f49a17b8e6..d0465321ba 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -421,6 +421,7 @@ export type UserSettingsState = { preventAutoStartAgentOnOpen: boolean; unreadDivider: boolean; agentGeneratedTaskTitles: boolean; + autoFocusNewTasks: boolean; mcpTaskAgentProfileDefault: MCPTaskAgentProfileDefault; showAnchoredPromptBar: boolean; showScrollToLastPrompt: boolean; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 52d4a902ea..1d5ccb630c 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -178,6 +178,7 @@ export type UserSettings = { prevent_auto_start_agent_on_open?: boolean; unread_divider?: boolean; agent_generated_task_titles?: boolean; + auto_focus_new_tasks?: boolean; mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; show_release_notification?: boolean; release_notes_last_seen_version?: string; @@ -253,6 +254,7 @@ export type UserSettingsUpdatePayload = { prevent_auto_start_agent_on_open?: boolean; unread_divider?: boolean; agent_generated_task_titles?: boolean; + auto_focus_new_tasks?: boolean; mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; show_release_notification?: boolean; release_notes_last_seen_version?: string; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 12057be267..a0ce8901c1 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "Add", "unitRemoveMember": "Remove member", "unitPickPerson": "Pick someone", - "unitActionFailed": "That did not work." + "unitActionFailed": "That did not work.", + "autoFocusNewTasks": "Auto-focus new tasks", + "autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested." } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 855353e1a4..5b53f7a90f 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "Àďď", "unitRemoveMember": "Ŕēḿōvē ḿēḿƀēŕ", "unitPickPerson": "Ƥĩćķ śōḿēōńē", - "unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ." + "unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ.", + "autoFocusNewTasks": "Àũţō-ƒōćũś ńēŵ ţàśķś", + "autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď." } diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index f86522a26e..6da8613089 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "Adicionar", "unitRemoveMember": "Remover membro", "unitPickPerson": "Escolher alguém", - "unitActionFailed": "Não foi possível concluir." + "unitActionFailed": "Não foi possível concluir.", + "autoFocusNewTasks": "Focar automaticamente novas tarefas", + "autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado." } diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 27ecdd4213..27faa5ac19 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "添加", "unitRemoveMember": "移除成员", "unitPickPerson": "选择成员", - "unitActionFailed": "操作未成功。" + "unitActionFailed": "操作未成功。", + "autoFocusNewTasks": "自动聚焦新任务", + "autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。" } diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index ed49ca25b0..c24b8cc96d 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "新增", "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", - "unitActionFailed": "操作未成功。" + "unitActionFailed": "操作未成功。", + "autoFocusNewTasks": "自動聚焦新任務", + "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。" } diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index ac5c20c87f..2b6f587c64 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -789,5 +789,7 @@ "unitAddMember": "新增", "unitRemoveMember": "移除成員", "unitPickPerson": "選擇成員", - "unitActionFailed": "操作未成功。" + "unitActionFailed": "操作未成功。", + "autoFocusNewTasks": "自動聚焦新任務", + "autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。" } diff --git a/docs/plans/task-creation-auto-focus/plan.md b/docs/plans/task-creation-auto-focus/plan.md new file mode 100644 index 0000000000..355a4110ac --- /dev/null +++ b/docs/plans/task-creation-auto-focus/plan.md @@ -0,0 +1,131 @@ +--- +created: 2026-09-13 +status: implemented +requirements: + - REQ-TASKS-CREATION-AUTO-FOCUS-001 +system_design: + - ../../specs/tasks/system-design/creation-auto-focus.md +legacy_specs: [] +--- + +# Implementation plan: Task creation auto-focus + +## Overview + +Deliver one vertical preference: persist a default-on setting, expose it in Task +Actions, and honor it at all shared task-creation completion boundaries. One +sequential work order keeps the preference and its behavior together. + +## Scope + +Include persistence, settings discovery, desktop/phone UI, localization, +creation completion, focused tests, and public task documentation. Exclude +per-task overrides, agent-start policy changes, Quick Chat, edit/session flows, +and automatic focus for background/API events. + +## Technical approach + +Follow the [design](../../specs/tasks/system-design/creation-auto-focus.md). +Use the existing settings JSON with a true missing-value default and the shared +save coordinator. Extend creation metadata with `autoFocus`; retain the +distinct meaning of `willNavigate`. Inspect every dialog caller before changes, +retain cache and completion work before suppression guards, and decouple plan +initialization from navigation. + +The per-user scope and Task Actions placement follow existing preference +patterns. User-confirmed behavior is an option to prevent focus with current +behavior as the default; this package proposes those routine UI/storage details. + +## ASCII UI preview + +UI-01: Settings > Task Behavior, saved default. Desktop and phone use +the same inline card composition: + +```text ++-------------------------------------------+ +| Auto-focus new tasks [ ON ] | +| Open newly created tasks automatically. | +| Turn this off to stay on your current | +| view. Tasks and agents still start as | +| requested. | ++-------------------------------------------+ + +After toggling: [ OFF ] (unsaved) +Existing shared action: [ Save changes ] +``` + +UI-02: Successful creation with the saved setting off: + +```text +Current task or listing -> [Create task] -> Same task or listing + dialog closes New task is available + for manual opening +``` + +Card hierarchy, visible help, and shared save ownership are structural. Spacing +and line breaks are illustrative. On phone, text wraps in the existing page +scroll region; the switch has a >=44px touch target and the existing save +control clears the safe area. No nested scroller or new drawer. A save failure +retains dirty state and existing shared error UI; discard restores the saved +switch value. Creation failure retains the existing dialog error flow. + +These views cover AC-TASKS-CREATION-AUTO-FOCUS-001.1–001.5 and are verified by +the E2E files below. + +## Tests + +Use TDD for changed logic. The work order owns exact commands. + +| AC suffix | Test location and cases | +| --- | --- | +| .1, .4 | New `creation_auto_focus_test.go` tests in user models/store/service and boot projection: true default, absent JSON, false/true round trip, omitted patch preservation | +| .1, .4 | `lib/ssr/user-settings.test.ts`: missing value true and explicit false through mapping; new settings component test for save/discard/rejection | +| .2, .3 | `task-create-dialog-submit.test.tsx`, helpers and caller suites: enabled/default behavior, false/no callback suppression, no-agent, agent, passthrough, plan, queued/no session, error and edit/session regressions | +| .2, .3 | Caller tests: sidebar, canvas, task header, phone switcher and board, asserting retained cache upserts and no route/active task/session/layout change | + +For new focused test files use the `creation-auto-focus.test.tsx` basename or +suffix so the work-order Vitest filter includes them. Extend existing suites +where their fixtures already cover the changed consumer. + +## E2E tests + +Add `e2e/tests/task/creation-auto-focus.spec.ts` (chromium) and +`e2e/tests/task/mobile-creation-auto-focus.spec.ts` (mobile-chrome). +Use existing task creation and mobile switcher fixtures, with no per-test +device overrides. Prove default-on behavior, saving off and reloading, +creation from a listing and an already active task, preserved route/selection, +manual opening, re-enabling, and unchanged explicit launch outcome. Verify +phone switch accessibility, >=44px target, containment, and focus return. +Unit/component tests cover the full caller and launch-mode matrix; E2E covers +the integrated user flows. Rebuild web and backend before browser tests. + +## Work orders + +- [x] [Task 01: Implement saved auto-focus preference](task-01-auto-focus-preference.md) + +## Verification results + +Implementation and verification completed on 2026-09-13. Backend persistence, +partial settings updates, boot projection, and settings catalog checks passed. +The broad frontend run passed 315 tests across 25 files; subsequent focused +runs verified final dialog focus restoration (11 tests), Azure launcher, and +settings fixture changes. TypeScript, changed-file ESLint, Prettier, +localization checks and ratchet, native backend builds, and web build passed. +Desktop and phone browser scenarios passed, including saved preference reload, +retained current view, manual opening, requested background agent execution, +re-enabled auto-focus, keyboard focus return, and phone touch/overflow checks. +Public documentation validation passed (62 tests, 46 pages). Specification +catalog/lint and whitespace validation passed. Exact commands and build scope +are recorded in the completed work order. + +## Risks + +- Missing/default coercion must not replace explicit false with true. +- Parent callbacks must not interpret suppressed navigation as permission to navigate. +- Planning task setup must survive disabled navigation without switching the current layout. +- Filtered lists need not reveal a newly created task; cache updates must still occur normally. + +## Documentation impact + +Updated `docs/public/tasks-and-workflows.md` with the setting location, enabled +default, saved scope, and unchanged requested agent-start behavior. diff --git a/docs/plans/task-creation-auto-focus/task-01-auto-focus-preference.md b/docs/plans/task-creation-auto-focus/task-01-auto-focus-preference.md new file mode 100644 index 0000000000..2b0919e872 --- /dev/null +++ b/docs/plans/task-creation-auto-focus/task-01-auto-focus-preference.md @@ -0,0 +1,186 @@ +--- +id: "01-auto-focus-preference" +title: "Implement saved task creation auto-focus preference" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-CREATION-AUTO-FOCUS-001 +acceptance_criteria: + - AC-TASKS-CREATION-AUTO-FOCUS-001.1 + - AC-TASKS-CREATION-AUTO-FOCUS-001.2 + - AC-TASKS-CREATION-AUTO-FOCUS-001.3 + - AC-TASKS-CREATION-AUTO-FOCUS-001.4 + - AC-TASKS-CREATION-AUTO-FOCUS-001.5 +system_design: + - ../../specs/tasks/system-design/creation-auto-focus.md +--- + +# Task 01: Saved task creation auto-focus preference + +## Summary + +Implement the saved default-on preference end to end. Gate only task-opening +side effects, preserving successful task creation and launch work. + +## In scope + +User settings default/persistence/projection, settings UI and discovery, +localized copy, dialog success metadata and all focus-owning consumers, +desktop/mobile regression evidence, and public task documentation. + +## Out of scope + +New per-task controls, runtime flags, changed agent-start rules, Quick Chat, +edit/additional-session navigation changes, and background task-event focus. + +## Acceptance + +1. The setting survives save/reload and partial updates with a true missing-value default, preserving false; the shared save/discard/error flow owns persistence. +2. Every creation completion consumer honors suppression without losing cache updates, dialog dismissal, plan setup, or requested launch. Enabled/absent policy preserves current behavior. +3. Localized desktop and phone settings and creation flows pass the mapped tests, including switch hitbox/overflow checks and manual opening after background creation. + +## ASCII UI preview + +UI-01, Task Actions; [full preview and state notes](plan.md#ascii-ui-preview): + +```text +Auto-focus new tasks [ ON ] +Open newly created tasks automatically. +Turn this off to stay on your current view. +Tasks and agents still start as requested. + +Changed to OFF -> shared [ Save changes ] +``` + +Shared desktop/phone hierarchy; phone text wraps, the control has a >=44px +touch target, and the existing page owns scrolling. UI-02: after successful +creation with saved OFF, close the dialog and retain the current view. Covers +AC-TASKS-CREATION-AUTO-FOCUS-001.1–001.5. + +## Verification + +Read `/tdd`, `/e2e`, scoped AGENTS files, and the assigned design before coding. +Write failing behavior tests first, then implement. Run from the repository +root; each command roots itself. In a fresh worktree, first install dependencies +with `(cd apps && pnpm install --frozen-lockfile)`. + +```bash +(cd apps/backend && go test ./internal/user/... ./internal/settingscatalog/...) +(cd apps/backend && go test ./internal/backendapp -run AutoFocusNewTasks -count=1) +(cd apps/backend && go run ./cmd/settings-catalog) +(cd apps/backend && go run ./cmd/settings-catalog --check) +(cd apps/web && pnpm exec vitest run creation-auto-focus task-create-dialog-submit task-create-dialog-helpers task-create-dialog.test.tsx app-sidebar-new-task-item canvas-task-create-launcher dockview-header-actions session-task-switcher-sheet kanban-board use-kanban-actions user-settings quick-task-launcher azure-devops-task-launcher improve-kandev-dialog app-sidebar-footer) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:zh-hant) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +make build-web +GOCACHE=/tmp/kandev-auto-focus-go-cache make -C apps/backend build-agentctl build-kandev build-mock-agent e2e-plugin-package +(cd apps/web && pnpm e2e:run --host --no-build --project=chromium e2e/tests/task/creation-auto-focus.spec.ts) +(cd apps/web && pnpm e2e:run --host --no-build --project=mobile-chrome e2e/tests/task/mobile-creation-auto-focus.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Name boot coverage `TestAutoFocusNewTasks...` so its command executes the new +test. Confirm the Vitest filter includes every modified caller suite; extend +the recorded exact command if inspection identifies another owner. Do not run +overlapping browser suites. Compare rendered UI to UI-01/UI-02 and record any +unresolved mismatch. + +## Files likely touched + +- `apps/backend/internal/user/{models/models.go,dto/dto.go,service/service.go,store/sqlite.go}` and handlers plus adjacent tests. +- `apps/backend/internal/backendapp/boot_state_routes.go` and a focused boot test; `apps/backend/internal/settingscatalog/defaults.go`. +- `apps/web/lib/types/http-user-settings.ts`, `lib/ssr/user-settings.ts`, settings slice/defaults, and discovery catalog/generated snapshots. +- New `apps/web/components/settings/creation-auto-focus-settings.tsx` and `creation-auto-focus-settings.test.tsx`; `general-settings.tsx`; five locale catalogs. +- `apps/web/components/task-create-dialog-{submit,helpers,types}.tsx` or `.ts` as existing, sidebar, canvas, board, task-header and mobile consumers named in the design, with their tests. +- `apps/web/hooks/domains/kanban/use-kanban-actions.ts` and its tests. +- GitHub/GitLab/Jira/Linear/Azure DevOps launchers and Improve Kandev success wrappers, plus their existing tests. +- New E2E files named above; `docs/public/tasks-and-workflows.md`. + +## Dependencies + +None. One sequential vertical slice. + +## Risks + +See [plan risks](plan.md#risks). Inventory success consumers with `rg` before +implementation; a guard in only the submit helper is incomplete. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/creation-auto-focus.md) +- [System design](../../specs/tasks/system-design/creation-auto-focus.md) +- Existing `prevent-auto-start-agent-settings.tsx` and `sqlite_prevent_auto_start_test.go` patterns. +- Existing `e2e/tests/task/create-task.spec.ts` and mobile settings/switcher tests. + +## Results + +Implementation and verification complete on 2026-09-13. Backend user/settings catalog and boot projection tests passed. The broad frontend run passed 315 tests across 25 files; subsequent focused runs passed the final dialog (11 tests), Azure launcher, and user-settings fixture changes. TypeScript, changed-file ESLint, Prettier, localization checks and ratchet, settings catalog consistency, native builds, and the web build passed. Desktop browser verification passed, including saved false after reload, retained listing/task URLs, opener focus, manual opening, background agent execution, and re-enabled navigation. Phone browser verification also passed, including the native Create only control, creation from the task switcher, saved preference, 44px touch target, no horizontal overflow, opener focus, background agent execution, and re-enabled navigation. Public documentation validation passed (62 tests, 46 pages). The initial geometry checks missed a mobile track distortion; see the correction below. + +Native builds replace the original full `make build-backend` command: the full target also cross-compiles remote helpers for Linux ARM and macOS, which these browser scenarios do not exercise. The full target was stopped after native agentctl built. Go checks use `GOCACHE=/tmp/kandev-auto-focus-go-cache` because the default cache is read-only in this environment. + + +### Review remediation + +Codex review identified detached drawer-opener focus and extended UI readiness +assertions. A phone E2E regression reproduced focus remaining on the document. +The drawer now passes its surviving opener to the creation dialog when +background creation is selected. The shared browser helper arms a wait for the +observed repository local-status response before opening creation, then checks +submit eligibility with the default assertion timeout. Local status supplies +the preferred default branch; the probe was removed after identifying this +causal chain. + +Local validation: 17 tests passed with +`pnpm --dir apps/web exec vitest run session-task-switcher-sheet.test.tsx task-create-dialog.test.tsx`; +changed-file ESLint, TypeScript, and `build:vite` passed. The focused phone +scenario passed with the added task-menu focus assertion; the desktop scenario +also passed. Remote CI/review verification remains pending after the fix push. + + +Claude's summary suggested verifying background plan-state isolation. Retained +session-keyed plan initialization as required by AC .3, and strengthened the +helper test to use real application/context stores. It verifies the background +plan document, mode, and context are prepared while the current task/session, +document, plan mode, and context files remain unchanged, with no navigation. +`pnpm --dir apps/web exec vitest run task-create-dialog-helpers.test.ts` +passed all 28 tests. This is regression coverage for the existing contract; +no production behavior changed for this suggestion. + + +CodeRabbit aggregate review: restored exact GitHub/GitLab navigation assertions +(12 focused launcher tests passed), removed transient repository-state wording, +and aligned documentation and browser navigation with the canonical +Settings > Task Behavior page. The existing Playwright fixture already inherits +the configured mobile context; explicit viewport, touch-point, and coarse-pointer +assertions now verify that invariant. The phone E2E scenario passed, as did +specification and public documentation validation. Desktop route verification +also passed. Remote checks remain pending for this final review update. + + +### Mobile switch presentation correction + +The user identified the circular mobile switch visible in the original PR +screenshot. Coarse-pointer minimum dimensions stretched the shared track while +leaving its thumb unchanged. Removed those overrides and extended only the +shared switch pseudo-element hit area vertically. The visible track retains its +28 by 16.6px pill geometry; the phone touch area is 52 by 44.6px. Desktop sizing +is unchanged. The owning page, labels, and save flow remain unchanged. + +The updated mobile scenario first failed on the circular aspect ratio, then +passed with the fix, including taps above the visible track for both states +and persistence through reload. Rendered checks covered 393px, 767px, 768px, +and desktop 1280px widths. Fresh on/off screenshots replace the flawed PR image. + +The focused desktop creation scenario also passed after the helper update. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index d29081e48e..78de01bcfc 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -21,6 +21,17 @@ A task is the work to deliver. A workflow is the sequence of steps it follows. U The task carries the outcome through the workflow. The repository and session provide the working context, while review remains an explicit human gate. +## Keep your view when creating tasks + +In **Settings > Task Behavior**, turn off **Auto-focus new tasks** +and select **Save changes** to create tasks without leaving your current view. +The setting is on by default and is saved with your user preferences across +reloads. It works on desktop and mobile. + +Tasks and agents still start as requested. You can open the new task manually +from the task list. This setting controls opening newly created tasks; it does +not change the separate preference for preventing agent auto-start on open. + ## Understand the model | Concept | What it controls | diff --git a/docs/specs/tasks/requirements/creation-auto-focus.md b/docs/specs/tasks/requirements/creation-auto-focus.md new file mode 100644 index 0000000000..35161c2499 --- /dev/null +++ b/docs/specs/tasks/requirements/creation-auto-focus.md @@ -0,0 +1,44 @@ +--- +status: active +system: tasks +created: 2026-09-13 +owners: + - kandev +--- + +# Task creation auto-focus + +## Overview + +Let users create tasks without interrupting the work they are viewing. Tasks +owns this contract because it governs completion of user-initiated task creation +across listing, task-detail, and mobile entry points. + +Auto-focus means automatically opening or selecting the newly created task or +session, including navigation and replacing a task preview. It does not mean +keyboard focus within the creation form. + +## Requirements + +### REQ-TASKS-CREATION-AUTO-FOCUS-001: Optional focus after creation + +**Intent:** Preserve current creation behavior by default while allowing users +to keep their current working context. + +#### Acceptance criteria + +- **AC-TASKS-CREATION-AUTO-FOCUS-001.1:** Settings > Task Behavior shall offer a per-user “Auto-focus new tasks” switch, enabled for new users and existing users without a saved value. With it enabled, each entry point shall preserve its current navigation and selection behavior. +- **AC-TASKS-CREATION-AUTO-FOCUS-001.2:** After the user saves the switch as disabled, successful task creation shall leave the current route, selected task/session, preview, and active layout unchanged. This includes ordinary creation, creation with an agent, passthrough agents, and planning tasks. With no task selected, none shall be selected automatically. +- **AC-TASKS-CREATION-AUTO-FOCUS-001.3:** Disabling auto-focus shall preserve task creation, requested agent or plan execution, task-list updates, successful dialog dismissal, and manual opening of the created task. It shall not change existing creation errors, retries, or cancellation behavior. +- **AC-TASKS-CREATION-AUTO-FOCUS-001.4:** Saving either switch value shall persist it across reloads through the user's settings. Unsaved edits and discarded edits shall not alter creation behavior. A failed save shall retain the previously effective value and expose the existing settings error/retry flow. +- **AC-TASKS-CREATION-AUTO-FOCUS-001.5:** Desktop and phone shall expose the same setting with visible explanatory text, an accessible switch, and the existing shared Save changes flow. Phone controls shall have a touch target of at least 44px, with wrapped text and no horizontal page overflow. After creation with auto-focus disabled, keyboard focus shall return to the surviving creation opener or an appropriate control on the unchanged surface. + +## Out of scope + +Per-task overrides; changing agent auto-start policy; changing explicit task +selection, task editing, additional-session creation, or Quick Chat behavior; +adding navigation for background/API/MCP creation. No new release toggle. + +## Implementation plans + +- [Creation auto-focus plan](../../../plans/task-creation-auto-focus/plan.md) diff --git a/docs/specs/tasks/system-design/creation-auto-focus.md b/docs/specs/tasks/system-design/creation-auto-focus.md new file mode 100644 index 0000000000..6a99bcc068 --- /dev/null +++ b/docs/specs/tasks/system-design/creation-auto-focus.md @@ -0,0 +1,117 @@ +--- +status: current +system: tasks +requirements: + - REQ-TASKS-CREATION-AUTO-FOCUS-001 +--- + +# Task creation auto-focus design + +## Boundary and existing behavior + +The task system owns completion of task creation. Reuse the existing per-user +settings contract; this adds one preference, without a new persistence system, +permission boundary, runtime flag, or task-creation API parameter. + +`task-create-dialog-submit.tsx` computes `willNavigate` for passthrough and plan +launches. Sidebar, task-header, canvas, and phone switcher success callbacks can +also navigate or select tasks. `willNavigate: false` currently means the caller +may navigate, so it cannot represent suppression. + +## Preference contract + +Add `AutoFocusNewTasks` / `auto_focus_new_tasks` to backend user settings and +`autoFocusNewTasks` to frontend state. The effective default is **true**. Extend +model defaults, DTO responses and pointer-valued patches, service update and +response mapping, SQLite JSON marshal/scan, HTTP/WS handler mappings, Go boot +payload, frontend HTTP types, SSR mapping, and store defaults. Decode missing +legacy JSON as true while preserving explicit false; partial updates to other +fields must preserve the saved value. No SQL column migration is needed. + +Use the existing `PreventAutoStartAgentOnOpen` path as the wiring exemplar, +but do not inherit its false default. Extend settings discovery in +`internal/settingscatalog/defaults.go` and +`lib/settings-discovery/catalog/preferences.ts`; regenerate both snapshots with +`go run ./cmd/settings-catalog` from `apps/backend`. + +## Creation completion policy + +At submission, snapshot the effective saved preference. Propagate a separate +optional `autoFocus` boolean in the shared creation success metadata, including +the no-agent submission path. Undefined preserves existing callback behavior. +Use `autoFocus !== false` at consumers; do not repurpose `willNavigate`. + +Always perform successful completion bookkeeping, cache upserts, draft clearing, +last-used updates, and dialog dismissal. Gate only route changes, active task +and session changes, preview changes, and layout switching. Keep `onSuccess` +delivery enabled. Compute `willNavigate` from both policy and existing launch +rules so enabled behavior still avoids duplicate navigation. + +Audit every `TaskCreateDialog` success consumer and forwarding type, including: + +- `components/app-sidebar/app-sidebar-new-task-item.tsx`. +- `components/task/dockview-header-actions.tsx`, `new-task-button.tsx`, and `new-task-dropdown.tsx`. +- `components/task/mobile/session-task-switcher-sheet-hooks.ts` and its dialogs. +- `components/canvas/canvas-task-create-launcher.tsx`. +- `components/kanban-board.tsx` and `handleDialogSuccess` in `hooks/domains/kanban/use-kanban-actions.ts` (cache-only; no focus change needed). +- GitHub, GitLab, Jira, Linear, and Azure DevOps task launchers: retain association/cache work, gate their final navigation. +- Improve Kandev creation forwards policy through its success wrapper to sidebar-footer and app-navigation callers. + +`activatePlanMode` in `task-create-dialog-helpers.ts` currently combines +session-keyed plan state/context initialization and router navigation. Separate +these responsibilities or give navigation an explicit option: initialize the +new session's plan state while suppressing navigation when requested. Do not +change the viewed task's layout or active session. Existing additional-session +and edit flows keep their current behavior. + +This policy does not add focus to entry points that currently stay put. Do not +introduce global task-created-event selection. Explicit user selection after +creation continues through existing navigation. + +## Settings and mobile presentation + +Add a small settings card next to existing task-action preferences, wired through +`useSettingsSaveContributor`. Reuse saved/draft reconciliation and failure +propagation from `prevent-auto-start-agent-settings.tsx`. Publish the effective +store value only after a successful save; discard restores the baseline. + +Copy: “Auto-focus new tasks”. Help: “Open newly created tasks automatically. +Turn this off to stay on your current view. Tasks and agents still start as +requested.” Localize via `t()` in English, Portuguese, and Chinese catalogs; +generate Traditional Chinese with `pnpm run i18n:zh-hant`. + +Desktop and phone share the inline card on `/settings/preferences/task-behavior`. +The closest settings exemplar is the existing auto-start preference; the phone +task navigation exemplar is `session-task-switcher-sheet.tsx`, which uses an +inset drawer. Keep the settings page's existing scroll owner and shared +safe-area-aware save control. Allow help text to wrap; use a real 44px touch +hit area on phone/coarse pointers while keeping desktop density. No new overlay +is needed for one persistent boolean. Creation shells retain their existing +phone composition. When automatic opening is disabled, the shared dialog captures +the opening control before child input-focus effects and restores it on dismissal +if it remains mounted. An explicit caller focus-return ref takes precedence. +The phone task drawer captures its own surviving opener before taking focus +and supplies that ref for background creation, since its New button unmounts +when the drawer closes. + +## Verification mapping + +| Criteria | Evidence | +| --- | --- | +| 001.1, 001.4 | Backend default/round-trip/partial-update tests; boot and SSR mapping tests; settings save/discard/failure component tests | +| 001.2, 001.3 | Submit and caller tests asserting no navigation/selection/layout mutation while creation, cache updates, and plan initialization continue | +| 001.1–001.5 | Desktop and mobile E2E for default on, saved off, reload, background creation, and manual opening; phone hitbox/overflow and focus return | + +## Failure and operational behavior + +Keep existing creation and settings error reporting. No new logging or metrics +are needed for a user presentation preference. The highest compatibility risks +are losing explicit false during hydration, skipping plan setup, and a caller +reintroducing navigation after the submit helper suppresses it. + +## Related contracts + +- [Requirements](../requirements/creation-auto-focus.md) +- [Prevent agent auto-start on open](../requirements/prevent-agent-autostart-on-open.md) +- [Mobile navigation](../../ui/requirements/mobile-task-navigation.md) +- [Implementation plan](../../../plans/task-creation-auto-focus/plan.md)