From 03e179c4d3c1edd1faf6876e013e8f6412e563bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:58:05 +0100 Subject: [PATCH] feat: add last task startup preference (#2102) --- apps/backend/internal/agent/agents/detect.go | 25 ++++ .../internal/agent/agents/hermes_acp.go | 21 ++-- .../internal/agent/agents/hermes_acp_test.go | 109 +++++++++++++++++- .../agent/agents/new_acp_agents_test.go | 2 +- .../server/process/comparison_base.go | 7 +- .../internal/backendapp/boot_state_routes.go | 1 + .../boot_state_user_settings_test.go | 11 ++ .../internal/user/controller/controller.go | 1 + .../user/controller/controller_test.go | 22 ++++ apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/dto/dto_test.go | 49 ++++++++ apps/backend/internal/user/models/models.go | 13 +++ apps/backend/internal/user/service/service.go | 19 +++ .../internal/user/service/service_test.go | 53 +++++++++ apps/backend/internal/user/store/sqlite.go | 10 +- .../internal/user/store/sqlite_test.go | 33 ++++++ .../components/danger-zone-section.test.ts | 2 +- apps/web/app/page-client.test.tsx | 71 +++++++++++- apps/web/app/page-client.tsx | 55 ++++++++- .../app-sidebar/app-sidebar-footer.test.tsx | 4 +- .../app-sidebar/app-sidebar-header.test.tsx | 2 +- .../app-sidebar/app-sidebar-nav-item.tsx | 7 +- .../app-sidebar-primary-nav.test.tsx | 8 ++ .../app-sidebar/app-sidebar-primary-nav.tsx | 3 +- .../app-sidebar-workspace-navigation.test.ts | 4 +- .../app-sidebar-workspace-navigation.ts | 8 +- .../app-sidebar-workspace-picker.test.tsx | 8 +- .../app-sidebar-workspace-picker.tsx | 2 +- apps/web/components/global-commands.tsx | 3 +- .../web/components/homepage-commands.test.tsx | 75 ++++++++++++ apps/web/components/homepage-commands.tsx | 25 +++- .../kanban/kanban-header-mobile.test.tsx | 2 +- apps/web/components/kanban/kanban-header.tsx | 30 +++-- .../components/kanban/mobile-menu-sheet.tsx | 8 +- apps/web/components/page-topbar.test.tsx | 2 +- apps/web/components/page-topbar.tsx | 15 ++- apps/web/components/settings/general-nav.ts | 2 +- .../components/settings/general-settings.tsx | 84 +++++++++++--- .../settings/settings-layout-client.tsx | 3 +- .../settings/startup-page-settings-card.tsx | 85 ++++++++++++++ .../task/mobile/session-mobile-top-bar.tsx | 6 +- apps/web/e2e/fixtures/test-base.ts | 3 + apps/web/e2e/helpers/api-client.ts | 2 + .../chat/mobile-quick-chat-entry.spec.ts | 5 +- .../e2e/tests/kanban/mobile-kanban.spec.ts | 7 +- .../tests/office/sidebar-navigation.spec.ts | 11 +- .../office/sidebar-workspace-mode.spec.ts | 14 ++- .../tests/plugins/mobile-plugin-nav.spec.ts | 2 +- .../settings/mobile-startup-page.spec.ts | 77 +++++++++++++ .../e2e/tests/settings/startup-page.spec.ts | 103 +++++++++++++++++ ...workspace-switch-sidebar-isolation.spec.ts | 2 +- .../domains/kanban/use-kanban-actions.ts | 15 ++- .../hooks/use-ensure-user-settings.test.ts | 1 + .../hooks/use-kanban-display-settings.test.ts | 89 ++++++++++++++ apps/web/hooks/use-kanban-display-settings.ts | 13 ++- apps/web/hooks/use-task-removal.test.ts | 7 +- apps/web/hooks/use-task-removal.ts | 4 +- apps/web/lib/links.test.ts | 11 ++ apps/web/lib/links.ts | 13 +++ apps/web/lib/recent-tasks.test.ts | 13 +++ apps/web/lib/recent-tasks.ts | 8 ++ apps/web/lib/ssr/user-settings.test.ts | 25 ++++ apps/web/lib/ssr/user-settings.ts | 8 +- apps/web/lib/startup-page.test.ts | 65 +++++++++++ apps/web/lib/startup-page.ts | 34 ++++++ apps/web/lib/state/slices/settings/types.ts | 2 + apps/web/lib/types/http-user-settings.ts | 3 + apps/web/lib/types/http.ts | 1 + .../web/lib/ws/handlers/tasks-archive.test.ts | 1 + .../web/lib/ws/handlers/tasks.deleted.test.ts | 1 + apps/web/lib/ws/handlers/tasks.ts | 4 +- apps/web/lib/ws/handlers/users.test.ts | 20 ++++ docs/public/tasks-and-workflows.md | 2 + .../ui/task-listing-display-preferences.md | 45 +++++++- 74 files changed, 1391 insertions(+), 113 deletions(-) create mode 100644 apps/web/components/homepage-commands.test.tsx create mode 100644 apps/web/components/settings/startup-page-settings-card.tsx create mode 100644 apps/web/e2e/tests/settings/mobile-startup-page.spec.ts create mode 100644 apps/web/e2e/tests/settings/startup-page.spec.ts create mode 100644 apps/web/hooks/use-kanban-display-settings.test.ts create mode 100644 apps/web/lib/startup-page.test.ts create mode 100644 apps/web/lib/startup-page.ts diff --git a/apps/backend/internal/agent/agents/detect.go b/apps/backend/internal/agent/agents/detect.go index 21529eeb7c..f3b1e09db8 100644 --- a/apps/backend/internal/agent/agents/detect.go +++ b/apps/backend/internal/agent/agents/detect.go @@ -3,8 +3,11 @@ package agents import ( "context" "os/exec" + "time" ) +const commandCheckTimeout = 5 * time.Second + // DetectOption is a detection strategy. Returns (found, matchedPath, err). type DetectOption func(ctx context.Context) (bool, string, error) @@ -19,6 +22,28 @@ func WithCommand(name string) DetectOption { } } +// WithCommandCheck checks that a command is on PATH and that its +// non-interactive capability check exits successfully. A failed check means +// the optional capability is unavailable; a cancelled context remains an +// actual discovery error. +func WithCommandCheck(name string, args ...string) DetectOption { + return func(ctx context.Context) (bool, string, error) { + path, err := exec.LookPath(name) + if err != nil { + return false, "", nil + } + checkCtx, cancel := context.WithTimeout(ctx, commandCheckTimeout) + defer cancel() + if err := exec.CommandContext(checkCtx, path, args...).Run(); err != nil { + if ctx.Err() != nil { + return false, "", ctx.Err() + } + return false, "", nil + } + return true, path, nil + } +} + // WithNpxRunnable reports the agent as runnable via `npx -y ` whenever // npx is on PATH. MatchedPath is tagged "npx " so the settings page // renders "Detected at npx ", giving users an honest hint that the diff --git a/apps/backend/internal/agent/agents/hermes_acp.go b/apps/backend/internal/agent/agents/hermes_acp.go index 586925cbcb..3117905cff 100644 --- a/apps/backend/internal/agent/agents/hermes_acp.go +++ b/apps/backend/internal/agent/agents/hermes_acp.go @@ -1,4 +1,4 @@ -//nolint:dupl // Native-binary ACP agents (Kiro, Qoder, Hermes, ...) follow the same minimal scaffold; differences are the binary name, argv, and auth surface. +//nolint:dupl,goconst // Native-binary ACP agents (Kiro, Qoder, Hermes, ...) follow the same minimal scaffold; differences are the binary name, argv, and auth surface. Shared literals live in every peer file by convention. package agents import ( @@ -38,12 +38,15 @@ func NewHermesACP() *HermesACP { StandardPassthrough: StandardPassthrough{ PermSettings: emptyPermSettings, Cfg: PassthroughConfig{ - Supported: true, - Label: "CLI Passthrough", - Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand(hermesBin), - IdleTimeout: 3 * time.Second, - BufferMaxBytes: DefaultBufferMaxBytes, + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand(hermesBin, "chat"), + ModelFlag: NewParam("--model", "{model}"), + ResumeFlag: NewParam("--continue"), + SessionResumeFlag: NewParam("--resume"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, }, }, } @@ -66,8 +69,8 @@ func (a *HermesACP) Logo(v LogoVariant) []byte { } func (a *HermesACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - result, err := Detect(ctx, WithCommand(hermesBin)) - if err != nil { + result, err := Detect(ctx, WithCommandCheck(hermesBin, "acp", "--check")) + if err != nil || !result.Available { return result, err } result.SupportsMCP = true diff --git a/apps/backend/internal/agent/agents/hermes_acp_test.go b/apps/backend/internal/agent/agents/hermes_acp_test.go index 2464644fbf..e85a5c7ab9 100644 --- a/apps/backend/internal/agent/agents/hermes_acp_test.go +++ b/apps/backend/internal/agent/agents/hermes_acp_test.go @@ -2,7 +2,11 @@ package agents import ( "context" + "errors" + "os" "os/exec" + "path/filepath" + "runtime" "slices" "strings" "testing" @@ -11,6 +15,22 @@ import ( "github.com/kandev/kandev/pkg/agent" ) +const hermesACPCheckHelperEnv = "KANDEV_TEST_HERMES_ACP_CHECK" + +func TestMain(m *testing.M) { + switch os.Getenv(hermesACPCheckHelperEnv) { + case "available": + if slices.Equal(os.Args[1:], []string{"acp", "--check"}) { + os.Exit(0) + } + os.Exit(1) + case "unavailable": + os.Exit(1) + default: + os.Exit(m.Run()) + } +} + func TestHermesACP_IDAndDisplay(t *testing.T) { a := NewHermesACP() if got := a.ID(); got != "hermes-acp" { @@ -52,7 +72,37 @@ func TestHermesACP_AllCommandSurfaces(t *testing.T) { if !ok { t.Fatal("HermesACP must implement PassthroughAgent") } - assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), []string{"hermes"}) + assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), []string{"hermes", "chat"}) +} + +func TestHermesACP_PassthroughPreservesModelAndSessions(t *testing.T) { + a := NewHermesACP() + + for _, tc := range []struct { + name string + opts PassthroughOptions + want []string + }{ + { + name: "model", + opts: PassthroughOptions{Model: "nous/hermes-4"}, + want: []string{"hermes", "chat", "--model", "nous/hermes-4"}, + }, + { + name: "last session", + opts: PassthroughOptions{Resume: true}, + want: []string{"hermes", "chat", "--continue"}, + }, + { + name: "specific session", + opts: PassthroughOptions{SessionID: "session-123"}, + want: []string{"hermes", "chat", "--resume", "session-123"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assertArgvEqual(t, "BuildPassthroughCommand", a.BuildPassthroughCommand(tc.opts).Args(), tc.want) + }) + } } // TestHermesACP_SkipsGlobalMCPStartup pins the host-owned MCP marker. Kandev @@ -99,6 +149,63 @@ func TestHermesACP_DetectionRequiresGlobalBinary(t *testing.T) { } } +func TestHermesACP_DetectionRequiresACPCheck(t *testing.T) { + installHermesACPCheckHelper(t) + + t.Run("available when acp check succeeds", func(t *testing.T) { + t.Setenv(hermesACPCheckHelperEnv, "available") + + result, err := NewHermesACP().IsInstalled(context.Background()) + if err != nil { + t.Fatalf("IsInstalled error: %v", err) + } + if !result.Available { + t.Fatal("Available=false when hermes acp --check succeeds") + } + }) + + t.Run("unavailable when acp check fails", func(t *testing.T) { + t.Setenv(hermesACPCheckHelperEnv, "unavailable") + + result, err := NewHermesACP().IsInstalled(context.Background()) + if err != nil { + t.Fatalf("IsInstalled error: %v", err) + } + if result.Available { + t.Fatal("Available=true when hermes acp --check fails") + } + }) + + t.Run("returns cancellation from caller context", func(t *testing.T) { + t.Setenv(hermesACPCheckHelperEnv, "available") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := NewHermesACP().IsInstalled(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("IsInstalled error = %v, want context.Canceled", err) + } + if result.Available { + t.Fatal("Available=true with a cancelled context") + } + }) +} + +func installHermesACPCheckHelper(t *testing.T) { + t.Helper() + + filename := hermesBin + if runtime.GOOS == "windows" { + filename += ".exe" + } + + path := filepath.Join(t.TempDir(), filename) + if err := os.Link(os.Args[0], path); err != nil { + t.Fatalf("link test helper binary: %v", err) + } + t.Setenv("PATH", filepath.Dir(path)+string(os.PathListSeparator)+os.Getenv("PATH")) +} + func TestHermesACP_LogosNonEmpty(t *testing.T) { a := NewHermesACP() if len(a.Logo(LogoLight)) == 0 { diff --git a/apps/backend/internal/agent/agents/new_acp_agents_test.go b/apps/backend/internal/agent/agents/new_acp_agents_test.go index d44d065887..15af7f54a4 100644 --- a/apps/backend/internal/agent/agents/new_acp_agents_test.go +++ b/apps/backend/internal/agent/agents/new_acp_agents_test.go @@ -154,7 +154,7 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null || id: "hermes-acp", displayName: "Hermes", detectBinaries: []string{"hermes"}, expectedArgv: []string{"hermes", "acp"}, inferenceArgv: []string{"hermes", "acp"}, - passthroughArgv: []string{"hermes"}, + passthroughArgv: []string{"hermes", "chat"}, installViaNpm: false, sessionDirTemplate: "{home}/.hermes", }}, diff --git a/apps/backend/internal/agentctl/server/process/comparison_base.go b/apps/backend/internal/agentctl/server/process/comparison_base.go index f514318906..8b2ef6ed12 100644 --- a/apps/backend/internal/agentctl/server/process/comparison_base.go +++ b/apps/backend/internal/agentctl/server/process/comparison_base.go @@ -5,7 +5,12 @@ import ( "strings" ) -var integrationBranchNames = [...]string{"main", "master"} +const ( + mainBranchName = "main" + masterBranchName = "master" +) + +var integrationBranchNames = [...]string{mainBranchName, masterBranchName} type comparisonBaseGit interface { GetMergeBase(ctx context.Context, ref1, ref2 string) (string, error) diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index a098424f37..b9206a4046 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -444,6 +444,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str return map[string]any{ "workspaceId": effectiveWorkspaceID, "kanbanViewMode": nullString(settings.KanbanViewMode), + "startupPage": usermodels.NormalizeStartupPage(settings.StartupPage), "workflowId": nullString(settings.WorkflowFilterID), "repositoryIds": stringSlice(settings.RepositoryIDs), "tasksListSort": usermodels.NormalizeTasksListSort(settings.TasksListSort), diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index dc9da476fc..585edc047b 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -39,6 +39,17 @@ func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *tes } } +func TestMapUserSettingsStateIncludesNormalizedStartupPage(t *testing.T) { + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{StartupPage: "future_value"}, + }, "workspace-1") + + got, ok := state["startupPage"].(string) + if !ok || got != usermodels.StartupPageTaskOverview { + t.Fatalf("startupPage = %#v, want task_overview", state["startupPage"]) + } +} + func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) { state := mapUserSettingsState(userdto.UserSettingsResponse{ Settings: userdto.UserSettingsDTO{AppStatusBarOrder: usermodels.AppStatusBarOrder{ diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index e4af3cde3e..2d248ffa6b 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -46,6 +46,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ WorkspaceID: req.WorkspaceID, KanbanViewMode: req.KanbanViewMode, + StartupPage: req.StartupPage, WorkflowFilterID: req.WorkflowFilterID, RepositoryIDs: req.RepositoryIDs, TasksListSort: req.TasksListSort, diff --git a/apps/backend/internal/user/controller/controller_test.go b/apps/backend/internal/user/controller/controller_test.go index 2b241b59e3..d331e932c0 100644 --- a/apps/backend/internal/user/controller/controller_test.go +++ b/apps/backend/internal/user/controller/controller_test.go @@ -68,6 +68,28 @@ func TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault(t *testing.T) { } } +func TestUpdateUserSettingsMapsStartupPage(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + repo := &settingsRepository{settings: &models.UserSettings{ + StartupPage: models.StartupPageTaskOverview, + }} + controller := NewController(service.NewService(repo, nil, log)) + want := models.StartupPageLastTask + + response, err := controller.UpdateUserSettings(context.Background(), dto.UpdateUserSettingsRequest{ + StartupPage: &want, + }) + if err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + if response.Settings.StartupPage != want { + t.Fatalf("StartupPage = %q, want %q", response.Settings.StartupPage, want) + } +} + func TestSystemMetricsDisplayPatch(t *testing.T) { t.Run("nil patch stays nil", func(t *testing.T) { if got := systemMetricsDisplayPatch(nil); got != nil { diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index c89978bb92..841b74ebc7 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -18,6 +18,7 @@ type UserSettingsDTO struct { UserID string `json:"user_id"` WorkspaceID string `json:"workspace_id"` KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` TasksListSort string `json:"tasks_list_sort"` @@ -83,6 +84,7 @@ type ShellOption struct { type UpdateUserSettingsRequest struct { WorkspaceID *string `json:"workspace_id,omitempty"` KanbanViewMode *string `json:"kanban_view_mode,omitempty"` + StartupPage *string `json:"startup_page,omitempty"` WorkflowFilterID *string `json:"workflow_filter_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` TasksListSort *string `json:"tasks_list_sort,omitempty"` @@ -209,6 +211,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { UserID: settings.UserID, WorkspaceID: settings.WorkspaceID, KanbanViewMode: settings.KanbanViewMode, + StartupPage: models.NormalizeStartupPage(settings.StartupPage), WorkflowFilterID: settings.WorkflowFilterID, RepositoryIDs: settings.RepositoryIDs, TasksListSort: settings.TasksListSort, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index c0d051e08f..49b01c4848 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -120,6 +120,55 @@ func TestFromUserSettingsIncludesNormalizedMCPTaskAgentProfileDefault(t *testing } } +func TestFromUserSettingsIncludesNormalizedStartupPage(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "last task", value: models.StartupPageLastTask, want: models.StartupPageLastTask}, + {name: "unknown defaults to task overview", value: "future_value", want: models.StartupPageTaskOverview}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := json.Marshal(FromUserSettings(&models.UserSettings{StartupPage: tt.value})) + if err != nil { + t.Fatalf("marshal DTO: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("decode DTO: %v", err) + } + if got := payload["startup_page"]; got != tt.want { + t.Fatalf("startup_page = %#v, want %q", got, tt.want) + } + }) + } +} + +func TestUpdateUserSettingsRequestStartupPagePatchSemantics(t *testing.T) { + t.Run("omitted value stays nil", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.StartupPage != nil { + t.Fatalf("StartupPage = %#v, want nil", req.StartupPage) + } + }) + + t.Run("explicit last task is retained", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"startup_page":"last_task"}`), &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.StartupPage == nil || *req.StartupPage != models.StartupPageLastTask { + t.Fatalf("StartupPage = %#v, want last_task", req.StartupPage) + } + }) +} + func TestUpdateUserSettingsRequestMCPTaskAgentProfileDefaultPatchSemantics(t *testing.T) { t.Run("omitted value stays nil", func(t *testing.T) { var req UpdateUserSettingsRequest diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 32c48bc315..99d7db3ee0 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -17,6 +17,18 @@ func NormalizeMCPTaskAgentProfileDefault(value string) string { return MCPTaskAgentProfileDefaultCurrentTask } +const ( + StartupPageTaskOverview = "task_overview" + StartupPageLastTask = "last_task" +) + +func NormalizeStartupPage(value string) string { + if value == StartupPageLastTask { + return value + } + return StartupPageTaskOverview +} + const ( // RoleAdmin unlocks user management and system settings mutation when // authentication is enabled. It does NOT grant visibility into other @@ -46,6 +58,7 @@ type UserSettings struct { UserID string `json:"user_id"` WorkspaceID string `json:"workspace_id"` KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` TasksListSort string `json:"tasks_list_sort"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 15ace2cdbe..dbb28f4ee1 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -37,6 +37,7 @@ type Service struct { type UpdateUserSettingsRequest struct { WorkspaceID *string KanbanViewMode *string + StartupPage *string WorkflowFilterID *string RepositoryIDs *[]string TasksListSort *string @@ -230,6 +231,9 @@ func applyWorkspaceAndTaskListPreferences(settings *models.UserSettings, req *Up if req.KanbanViewMode != nil { settings.KanbanViewMode = *req.KanbanViewMode } + if err := applyStartupPage(settings, req.StartupPage); err != nil { + return err + } if req.WorkflowFilterID != nil { settings.WorkflowFilterID = *req.WorkflowFilterID } @@ -257,6 +261,20 @@ func applyWorkspaceAndTaskListPreferences(settings *models.UserSettings, req *Up return nil } +func applyStartupPage(settings *models.UserSettings, value *string) error { + if value == nil { + return nil + } + v := strings.TrimSpace(*value) + switch v { + case models.StartupPageTaskOverview, models.StartupPageLastTask: + settings.StartupPage = v + return nil + default: + return fmt.Errorf("startup_page must be %q or %q", models.StartupPageTaskOverview, models.StartupPageLastTask) + } +} + func applyTaskActionPreferences(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { if req.ReviewAutoMarkOnScroll != nil { settings.ReviewAutoMarkOnScroll = *req.ReviewAutoMarkOnScroll @@ -648,6 +666,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "user_id": settings.UserID, "workspace_id": settings.WorkspaceID, "kanban_view_mode": settings.KanbanViewMode, + "startup_page": models.NormalizeStartupPage(settings.StartupPage), "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, "tasks_list_sort": settings.TasksListSort, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index f2bbfd5c02..b743810bf4 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -444,6 +444,41 @@ func TestApplyChangesPanelLayout(t *testing.T) { }) } +func TestApplyStartupPage(t *testing.T) { + t.Run("omission preserves saved value", func(t *testing.T) { + settings := &models.UserSettings{StartupPage: models.StartupPageLastTask} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if settings.StartupPage != models.StartupPageLastTask { + t.Fatalf("StartupPage = %q, want %q", settings.StartupPage, models.StartupPageLastTask) + } + }) + + for _, value := range []string{models.StartupPageTaskOverview, models.StartupPageLastTask} { + t.Run("applies "+value, func(t *testing.T) { + settings := &models.UserSettings{StartupPage: models.StartupPageTaskOverview} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{StartupPage: ptr(value)}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if settings.StartupPage != value { + t.Fatalf("StartupPage = %q, want %q", settings.StartupPage, value) + } + }) + } + + t.Run("rejects invalid value", func(t *testing.T) { + settings := &models.UserSettings{StartupPage: models.StartupPageLastTask} + err := applyBasicSettings(settings, &UpdateUserSettingsRequest{StartupPage: ptr("future_value")}) + if err == nil { + t.Fatal("expected invalid startup page error") + } + if settings.StartupPage != models.StartupPageLastTask { + t.Fatalf("StartupPage = %q, want unchanged %q", settings.StartupPage, models.StartupPageLastTask) + } + }) +} + func TestApplyBasicSettings_TerminalFontSize(t *testing.T) { t.Run("nil leaves settings unchanged", func(t *testing.T) { settings := &models.UserSettings{TerminalFontSize: 14} @@ -1007,6 +1042,24 @@ func TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault(t } } +func TestPublishUserSettingsEventIncludesNormalizedStartupPage(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + eventBus := &recordingEventBus{} + svc := NewService(&recordingUserRepository{}, eventBus, log) + svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{StartupPage: "future_value"}) + + eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data) + } + if got := eventData["startup_page"]; got != models.StartupPageTaskOverview { + t.Fatalf("startup_page = %#v, want task_overview", got) + } +} + func TestPublishUserSettingsEventIncludesAppStatusBarOrder(t *testing.T) { log, err := logger.NewFromZap(zap.NewNop()) if err != nil { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 310792f75a..aa68d37dad 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -17,6 +17,8 @@ import ( const ( DefaultUserID = "default-user" DefaultUserEmail = "default@kandev.local" + + defaultChangesPanelLayout = "tree" ) type sqliteRepository struct { @@ -444,6 +446,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { return json.Marshal(map[string]interface{}{ "workspace_id": settings.WorkspaceID, "kanban_view_mode": settings.KanbanViewMode, + "startup_page": models.NormalizeStartupPage(settings.StartupPage), "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, "tasks_list_sort": models.NormalizeTasksListSort(settings.TasksListSort), @@ -569,6 +572,7 @@ func mergeVoiceModeDefaults(stored *storedVoiceMode) models.VoiceModeSettings { func defaultUserSettings(userID string) *models.UserSettings { return &models.UserSettings{ UserID: userID, + StartupPage: models.StartupPageTaskOverview, RepositoryIDs: []string{}, TasksListSort: models.TasksListSortDefault, TasksListGroup: models.TasksListGroupDefault, @@ -588,7 +592,7 @@ func defaultUserSettings(userID string) *models.UserSettings { ChatSubmitKey: "cmd_enter", KeyboardShortcuts: map[string]interface{}{}, TerminalLinkBehavior: "new_tab", - ChangesPanelLayout: "tree", + ChangesPanelLayout: defaultChangesPanelLayout, SidebarViews: []models.SidebarView{}, SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), @@ -608,6 +612,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin var payload struct { WorkspaceID string `json:"workspace_id"` KanbanViewMode string `json:"kanban_view_mode"` + StartupPage string `json:"startup_page"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` TasksListSort string `json:"tasks_list_sort"` @@ -658,6 +663,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.WorkspaceID = payload.WorkspaceID settings.KanbanViewMode = payload.KanbanViewMode + settings.StartupPage = models.NormalizeStartupPage(payload.StartupPage) settings.WorkflowFilterID = payload.WorkflowFilterID settings.RepositoryIDs = payload.RepositoryIDs if settings.RepositoryIDs == nil { @@ -747,7 +753,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if payload.ChangesPanelLayout == "flat" { settings.ChangesPanelLayout = "flat" } else { - settings.ChangesPanelLayout = "tree" + settings.ChangesPanelLayout = defaultChangesPanelLayout } return settings, nil } diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 217ecd3cb7..a31632d4ad 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -36,6 +36,39 @@ func (s settingsScanner) Scan(dest ...any) error { return nil } +func TestScanUserSettingsStartupPage(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "empty settings default to task overview", raw: "{}", want: "task_overview"}, + {name: "missing setting defaults to task overview", raw: `{"chat_submit_key":"cmd_enter"}`, want: "task_overview"}, + {name: "unknown setting defaults to task overview", raw: `{"startup_page":"future_value"}`, want: "task_overview"}, + {name: "last task is preserved", raw: `{"startup_page":"last_task"}`, want: "last_task"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: tt.raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + encoded, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatalf("marshal settings payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode normalized settings: %v", err) + } + if got := payload["startup_page"]; got != tt.want { + t.Fatalf("startup_page = %#v, want %q", got, tt.want) + } + }) + } +} + func TestScanUserSettingsChangesPanelLayoutDefault(t *testing.T) { t.Run("empty settings default to tree", func(t *testing.T) { settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID) diff --git a/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts b/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts index 0d4d18d020..c547c37797 100644 --- a/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts +++ b/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts @@ -32,7 +32,7 @@ describe("workspace delete navigation", () => { const next = resolvePostDeleteWorkspace(deleted.id, [deleted, kanban]); expect(next).toBe(kanban); - expect(postDeleteWorkspaceHref(next)).toBe("/?workspaceId=kanban-1"); + expect(postDeleteWorkspaceHref(next)).toBe("/?home=overview&workspaceId=kanban-1"); }); it("routes to the new office workspace wizard when no workspaces remain", () => { diff --git a/apps/web/app/page-client.test.tsx b/apps/web/app/page-client.test.tsx index 094c18948d..383802c8de 100644 --- a/apps/web/app/page-client.test.tsx +++ b/apps/web/app/page-client.test.tsx @@ -1,10 +1,18 @@ import { render, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const replaceMock = vi.hoisted(() => vi.fn()); +const kanbanWithPreviewMock = vi.hoisted(() => vi.fn(() => null)); +const startupPageMock = vi.hoisted(() => ({ value: "task_overview" })); +const recentTasksMock = vi.hoisted(() => ({ + entries: [] as Array<{ taskId: string; workspaceId: string }>, +})); +const getRecentTasksMock = vi.hoisted(() => vi.fn()); +const searchMock = vi.hoisted(() => ({ value: "" })); vi.mock("@/components/kanban-with-preview", () => ({ - KanbanWithPreview: () => null, + KanbanWithPreview: kanbanWithPreviewMock, })); vi.mock("@/components/onboarding-dialog", () => ({ OnboardingDialog: () => null, @@ -14,12 +22,33 @@ vi.mock("@/hooks/use-task-listing-view", () => ({ })); vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ replace: replaceMock }), + useSearchParams: () => new URLSearchParams(searchMock.value), +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: { userSettings: { startupPage: string } }) => unknown) => + selector({ userSettings: { startupPage: startupPageMock.value } }), +})); +vi.mock("@/lib/recent-tasks", () => ({ + getRecentTasks: getRecentTasksMock, + findMostRecentTaskForWorkspace: ( + entries: Array<{ taskId: string; workspaceId: string }>, + workspaceId?: string, + ) => entries.find((entry) => entry.workspaceId === workspaceId) ?? null, })); import { PageClient } from "./page-client"; +beforeEach(() => { + getRecentTasksMock.mockImplementation(() => recentTasksMock.entries); +}); + afterEach(() => { replaceMock.mockReset(); + kanbanWithPreviewMock.mockClear(); + getRecentTasksMock.mockReset(); + startupPageMock.value = "task_overview"; + recentTasksMock.entries = []; + searchMock.value = ""; }); describe("PageClient", () => { @@ -46,4 +75,42 @@ describe("PageClient", () => { expect(replaceMock).not.toHaveBeenCalled(); }); }); + + it("replaces bare startup with the newest recent task in the active workspace", async () => { + startupPageMock.value = "last_task"; + recentTasksMock.entries = [ + { taskId: "foreign-task", workspaceId: "workspace-2" }, + { taskId: "last-task", workspaceId: "workspace-1" }, + ]; + + render(); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/t/last-task"); + }); + expect(kanbanWithPreviewMock).not.toHaveBeenCalled(); + }); + + it("does not read browser recent tasks during server rendering", () => { + startupPageMock.value = "last_task"; + recentTasksMock.entries = [{ taskId: "last-task", workspaceId: "workspace-1" }]; + + const markup = renderToString(); + + expect(markup).toContain("Opening last task…"); + expect(getRecentTasksMock).not.toHaveBeenCalled(); + }); + + it("keeps an explicit overview entry from resuming the last task", async () => { + startupPageMock.value = "last_task"; + recentTasksMock.entries = [{ taskId: "last-task", workspaceId: "workspace-1" }]; + searchMock.value = "home=overview"; + + render(); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/tasks?workspace=workspace-1"); + }); + expect(replaceMock).not.toHaveBeenCalledWith("/t/last-task"); + }); }); diff --git a/apps/web/app/page-client.tsx b/apps/web/app/page-client.tsx index fc35338b02..ef7e783ffb 100644 --- a/apps/web/app/page-client.tsx +++ b/apps/web/app/page-client.tsx @@ -4,11 +4,14 @@ import { useEffect, useState } from "react"; import { KanbanWithPreview } from "@/components/kanban-with-preview"; import { OnboardingDialog } from "@/components/onboarding-dialog"; +import { useAppStore } from "@/components/state-provider"; import { getLocalStorage, setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; -import { useRouter } from "@/lib/routing/client-router"; +import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { useTaskListingView } from "@/hooks/use-task-listing-view"; -import { linkToTasks } from "@/lib/links"; +import { linkToTask, linkToTasks } from "@/lib/links"; +import { getRecentTasks } from "@/lib/recent-tasks"; +import { isExplicitHomeDestination, resolveStartupTaskId } from "@/lib/startup-page"; import { shouldRestoreHomeTaskListingView } from "@/lib/task-listing/view-preference"; type PageClientProps = { @@ -19,7 +22,24 @@ type PageClientProps = { export function PageClient({ workspaceId, initialTaskId, initialSessionId }: PageClientProps) { const router = useRouter(); + const searchParams = useSearchParams(); const { preferredView } = useTaskListingView(); + const startupPage = useAppStore((state) => state.userSettings.startupPage); + const hasExplicitDestination = isExplicitHomeDestination( + searchParams, + initialTaskId, + initialSessionId, + ); + const [recentTasks, setRecentTasks] = useState | null>(null); + const startupTaskId = resolveStartupTaskId({ + startupPage, + workspaceId, + recentTasks: recentTasks ?? [], + hasExplicitDestination, + }); + const isResolvingStartupTask = + startupPage === "last_task" && !hasExplicitDestination && recentTasks === null; + const hasWorkflowFilter = Boolean(searchParams.get("workflowId")); const [showOnboarding, setShowOnboarding] = useState(() => { if (typeof window === "undefined") return false; const completed = getLocalStorage(STORAGE_KEYS.ONBOARDING_COMPLETED, false); @@ -34,10 +54,39 @@ export function PageClient({ workspaceId, initialTaskId, initialSessionId }: Pag }; useEffect(() => { + setRecentTasks(getRecentTasks()); + }, []); + + useEffect(() => { + if (isResolvingStartupTask) return; + if (startupTaskId) { + router.replace(linkToTask(startupTaskId)); + return; + } + if (hasWorkflowFilter) return; if (shouldRestoreHomeTaskListingView(preferredView, initialTaskId, initialSessionId)) { router.replace(linkToTasks(workspaceId)); } - }, [initialSessionId, initialTaskId, preferredView, router, workspaceId]); + }, [ + hasWorkflowFilter, + initialSessionId, + initialTaskId, + isResolvingStartupTask, + preferredView, + router, + startupTaskId, + workspaceId, + ]); + + if (isResolvingStartupTask || startupTaskId) { + return ( +
+

+ Opening last task… +

+
+ ); + } return ( <> diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index ebb232079b..5f61957707 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -187,7 +187,7 @@ describe("AppSidebarFooter", () => { expect(screen.queryByRole("button", { name: "Office" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Kanban" })); - expect(mocks.routerPush).toHaveBeenCalledWith("/?workspaceId=kanban-1"); + expect(mocks.routerPush).toHaveBeenCalledWith("/?home=overview&workspaceId=kanban-1"); }); it("remembers the current office workspace when toggling back to kanban", () => { @@ -200,7 +200,7 @@ describe("AppSidebarFooter", () => { fireEvent.click(screen.getByRole("button", { name: "Kanban" })); expect(document.cookie).toContain("office-active-workspace=office-2"); - expect(mocks.routerPush).toHaveBeenCalledWith("/?workspaceId=kanban-1"); + expect(mocks.routerPush).toHaveBeenCalledWith("/?home=overview&workspaceId=kanban-1"); }); it("navigates to /settings when the gear opens settings mode from a non-settings route", () => { pathname = DEFAULT_PATHNAME; diff --git a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx index 29c57dc084..0bd9801337 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx @@ -41,7 +41,7 @@ describe("AppSidebarHeader", () => { renderHeader(); expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe( - "/?workspaceId=kanban-1", + "/?home=overview&workspaceId=kanban-1", ); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx index 918859a382..9814e07ad4 100644 --- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -70,9 +70,10 @@ function renderTrigger({ onClick, disabled, baseClass, label, href, inner, testI function isPathActive(pathname: string, href: string | undefined, exactMatch: boolean): boolean { if (!href) return false; - if (exactMatch) return pathname === href; - if (pathname === href) return true; - return href !== "/" && pathname.startsWith(`${href}/`); + const hrefPathname = href.split(/[?#]/, 1)[0] || "/"; + if (exactMatch) return pathname === hrefPathname; + if (pathname === hrefPathname) return true; + return hrefPathname !== "/" && pathname.startsWith(`${hrefPathname}/`); } export function AppSidebarNavItem({ diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx index 49356b8dbd..313c0cb921 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx @@ -76,4 +76,12 @@ describe("AppSidebarPrimaryNav", () => { expect(screen.queryByRole("button", { name: "Quick Chat" })).toBeNull(); }); + + it("links Home to an explicit overview while keeping it active at the root route", () => { + renderNav(false); + + const home = screen.getByRole("link", { name: "Home" }); + expect(home.getAttribute("href")).toBe("/?home=overview&workspaceId=ws-1"); + expect(home.className).toContain("before:bg-primary"); + }); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index 1bed73a043..b42a7fed8f 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -4,6 +4,7 @@ import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useInOffice } from "@/hooks/use-in-office"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { linkToTaskOverview } from "@/lib/links"; import { AppSidebarNavItem } from "./app-sidebar-nav-item"; import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; @@ -22,7 +23,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts index db0e284bc4..153f7738f6 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.test.ts @@ -27,9 +27,9 @@ describe("app sidebar workspace navigation", () => { }); it("routes workspace home by active workspace type", () => { - expect(workspaceHomeHref(kanban)).toBe("/?workspaceId=kanban-1"); + expect(workspaceHomeHref(kanban)).toBe("/?home=overview&workspaceId=kanban-1"); expect(workspaceHomeHref(office)).toBe("/office?workspaceId=office-1"); - expect(workspaceHomeHref(undefined)).toBe("/"); + expect(workspaceHomeHref(undefined)).toBe("/?home=overview"); }); it("remembers and resolves the last kanban workspace", () => { diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts index d20d4c6cc0..3fe2488547 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-navigation.ts @@ -1,3 +1,5 @@ +import { linkToTaskOverview } from "@/lib/links"; + export type SidebarWorkspace = { id: string; office_workflow_id?: string | null; @@ -13,9 +15,9 @@ export function isOfficeWorkspace(workspace: SidebarWorkspace | undefined): bool } export function workspaceHomeHref(workspace: SidebarWorkspace | undefined): string { - if (!workspace) return "/"; - const path = isOfficeWorkspace(workspace) ? "/office" : "/"; - return `${path}?workspaceId=${workspace.id}`; + if (!workspace) return linkToTaskOverview(); + if (!isOfficeWorkspace(workspace)) return linkToTaskOverview({ workspaceId: workspace.id }); + return `/office?workspaceId=${workspace.id}`; } export function rememberLastKanbanWorkspace(workspace: SidebarWorkspace | undefined): void { diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index 077ba72e9d..013ea40b27 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -171,7 +171,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { storeState.setActiveWorkspace.mock.invocationCallOrder[0], ); expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w3"); - expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w3"); + expect(navigationMock.push).toHaveBeenCalledWith("/?home=overview&workspaceId=w3"); }); it("calls onActionComplete when selecting a different workspace", () => { @@ -195,7 +195,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w1"))).toBe(false); expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w2"))).toBe(true); expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1"); - expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w1"); + expect(navigationMock.push).toHaveBeenCalledWith("/?home=overview&workspaceId=w1"); }); it("navigates to the office workspace when the office feature is enabled", () => { @@ -217,7 +217,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w2"))).toBe(true); expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1"); - expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w1"); + expect(navigationMock.push).toHaveBeenCalledWith("/?home=overview&workspaceId=w1"); }); }); @@ -252,7 +252,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( expect(cookieWrites.some((c) => c.startsWith("kandev-active-workspace=w1"))).toBe(true); expect(storeState.setActiveWorkspace).not.toHaveBeenCalled(); - expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w1"); + expect(navigationMock.push).toHaveBeenCalledWith("/?home=overview&workspaceId=w1"); }); it("does not route when selecting the active office workspace", () => { diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index beb0e3089a..b7146195d3 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -245,7 +245,7 @@ export function AppSidebarWorkspacePicker({ resetKanbanWorkspaceContext(); setActiveWorkspace(id); if (type === "kanban") { - router.push(`/?workspaceId=${id}`); + router.push(workspaceHomeHref(workspace)); } else if (officeEnabled) { router.push(`/office?workspaceId=${id}`); } diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx index 26e506e030..353b0f4d6b 100644 --- a/apps/web/components/global-commands.tsx +++ b/apps/web/components/global-commands.tsx @@ -24,6 +24,7 @@ import { usePluginShortcuts } from "@/hooks/use-plugin-shortcuts"; import { useAppStore } from "@/components/state-provider"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { linkToTaskOverview } from "@/lib/links"; import type { CommandItem } from "@/lib/commands/types"; type PushFn = ReturnType["push"]; @@ -36,7 +37,7 @@ function buildNavigationCommands(push: PushFn): CommandItem[] { group: "Navigation", icon: , keywords: ["home", "kanban", "board"], - action: () => push("/"), + action: () => push(linkToTaskOverview()), }, { id: "nav-tasks", diff --git a/apps/web/components/homepage-commands.test.tsx b/apps/web/components/homepage-commands.test.tsx new file mode 100644 index 0000000000..107caf64dd --- /dev/null +++ b/apps/web/components/homepage-commands.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + routerPush: vi.fn(), + onViewModeChange: vi.fn(), + commands: [] as Array<{ id: string; action?: () => void }>, + activeWorkspaceId: "workspace-1" as string | null, + activeWorkflowId: "workflow-1" as string | null, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + userSettings: { keyboardShortcuts: {} }, + workspaces: { activeId: mocks.activeWorkspaceId }, + workflows: { activeId: mocks.activeWorkflowId }, + }), +})); +vi.mock("@/hooks/use-kanban-display-settings", () => ({ + useKanbanDisplaySettings: () => ({ onViewModeChange: mocks.onViewModeChange }), +})); +vi.mock("@/hooks/use-register-commands", () => ({ + useRegisterCommands: (commands: Array<{ id: string; action?: () => void }>) => { + mocks.commands = commands; + }, +})); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: false }), +})); +vi.mock("@/lib/keyboard/shortcut-overrides", () => ({ + getShortcut: () => undefined, +})); +vi.mock("@/lib/routing/client-router", () => ({ + useRouter: () => ({ push: mocks.routerPush }), +})); + +import { HomepageCommands } from "./homepage-commands"; + +function commandAction(id: string): () => void { + const action = mocks.commands.find((command) => command.id === id)?.action; + if (!action) throw new Error(`Missing ${id} command action`); + return action; +} + +beforeEach(() => { + mocks.routerPush.mockReset(); + mocks.onViewModeChange.mockReset(); + mocks.commands = []; + mocks.activeWorkspaceId = "workspace-1"; + mocks.activeWorkflowId = "workflow-1"; +}); + +afterEach(() => cleanup()); + +describe("HomepageCommands", () => { + it("preserves the active scope when changing views", () => { + render(); + + commandAction("view-kanban")(); + expect(mocks.routerPush).toHaveBeenCalledWith( + "/?home=overview&workspaceId=workspace-1&workflowId=workflow-1", + ); + + mocks.routerPush.mockClear(); + commandAction("view-pipeline")(); + expect(mocks.routerPush).toHaveBeenCalledWith( + "/?home=overview&workspaceId=workspace-1&workflowId=workflow-1", + ); + + mocks.routerPush.mockClear(); + commandAction("view-list")(); + expect(mocks.routerPush).toHaveBeenCalledWith("/tasks?workspace=workspace-1"); + }); +}); diff --git a/apps/web/components/homepage-commands.tsx b/apps/web/components/homepage-commands.tsx index 9fa4b1e31d..e3ca33b29e 100644 --- a/apps/web/components/homepage-commands.tsx +++ b/apps/web/components/homepage-commands.tsx @@ -5,7 +5,7 @@ import { useRouter } from "@/lib/routing/client-router"; import { IconLayoutKanban, IconGitBranch, IconList, IconPlus } from "@tabler/icons-react"; import { useRegisterCommands } from "@/hooks/use-register-commands"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; -import { linkToTasks } from "@/lib/links"; +import { linkToTaskOverview, linkToTasks } from "@/lib/links"; import type { CommandItem } from "@/lib/commands/types"; import { useAppStore } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; @@ -20,7 +20,14 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { const { onViewModeChange } = useKanbanDisplaySettings(); const { isMobile } = useResponsiveBreakpoint(); const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const activeWorkflowId = useAppStore((s) => s.workflows.activeId); const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts); + const taskOverviewHref = linkToTaskOverview({ + workspaceId: activeWorkspaceId ?? undefined, + workflowId: activeWorkflowId ?? undefined, + }); + const tasksHref = linkToTasks(activeWorkspaceId ?? undefined); const commands = useMemo(() => { const items: CommandItem[] = [ @@ -42,7 +49,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { keywords: ["kanban", "board", "view"], priority: 0, action: () => { - router.push("/"); + router.push(taskOverviewHref); if (!isMobile) onViewModeChange(""); }, }, @@ -57,7 +64,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { keywords: ["pipeline", "graph", "view"], priority: 0, action: () => { - router.push("/"); + router.push(taskOverviewHref); onViewModeChange("graph2"); }, }); @@ -70,11 +77,19 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { icon: , keywords: ["list", "table", "view"], priority: 0, - action: () => router.push(linkToTasks()), + action: () => router.push(tasksHref), }); return items; - }, [onCreateTask, router, onViewModeChange, newTaskShortcut, isMobile]); + }, [ + onCreateTask, + router, + onViewModeChange, + newTaskShortcut, + isMobile, + taskOverviewHref, + tasksHref, + ]); useRegisterCommands(commands); diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index c020480fbd..c39830b00d 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -71,7 +71,7 @@ describe("KanbanHeaderMobile", () => { renderHeader("Home", "workspace-1"); expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe( - "/?workspaceId=workspace-1", + "/?home=overview&workspaceId=workspace-1", ); expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).toBe(""); }); diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 177153a381..0ee6158ea7 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -21,7 +21,7 @@ import { KanbanHeaderMobile } from "./kanban-header-mobile"; import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions"; import { MobileMenuSheet } from "./mobile-menu-sheet"; import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options"; -import { linkToTasks } from "@/lib/links"; +import { linkToTaskOverview, linkToTasks } from "@/lib/links"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; @@ -68,6 +68,13 @@ function getHeaderTitle(currentPage: string): string { return currentPage === "tasks" ? "Tasks" : "Home"; } +function toHeaderHealthProps(health: ReturnType) { + return { + showHealthIndicator: health.hasIssues, + onOpenHealthDialog: health.openDialog, + }; +} + // Integrations / Stats / Office / Improve Kandev / Settings / Release notes // have all moved to the unified AppSidebar (Fix 6). The kanban top bar now // focuses on task-creation, view-toggle, kanban display, and search. @@ -300,9 +307,10 @@ function DesktopHeader({ ); } -function useHeaderViewChange( +function useHeaderView( currentPage: string, workspaceId: string | undefined, + workflowId: string | null, onViewModeChange: (mode: string) => void, ) { const router = useRouter(); @@ -312,10 +320,12 @@ function useHeaderViewChange( if (currentPage !== "tasks") router.push(linkToTasks(workspaceId)); } else if (value === "kanban") { onViewModeChange("kanban"); - if (currentPage !== "kanban") router.push("/"); + if (currentPage !== "kanban") + router.push(linkToTaskOverview({ workspaceId, workflowId: workflowId ?? undefined })); } else if (value === "pipeline") { onViewModeChange("pipeline"); - if (currentPage !== "kanban") router.push("/"); + if (currentPage !== "kanban") + router.push(linkToTaskOverview({ workspaceId, workflowId: workflowId ?? undefined })); } }; } @@ -332,19 +342,17 @@ export function KanbanHeader({ const { isMobile, isTablet } = useResponsiveBreakpoint(); const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); - const { effectiveTaskListingView, onViewModeChange, workspaces, activeWorkspaceId } = - useKanbanDisplaySettings(); + const workflowId = useAppStore((state) => state.workflows.activeId); + const display = useKanbanDisplaySettings(); + const { effectiveTaskListingView, onViewModeChange, workspaces, activeWorkspaceId } = display; const releaseNotes = useReleaseNotes(); const healthIndicator = useSystemHealthIndicator(); const toggleValue = currentPage === "tasks" ? "list" : effectiveTaskListingView; - const handleViewChange = useHeaderViewChange(currentPage, workspaceId, onViewModeChange); + const handleViewChange = useHeaderView(currentPage, workspaceId, workflowId, onViewModeChange); const title = getHeaderTitle(currentPage); const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId); - const healthProps = { - showHealthIndicator: healthIndicator.hasIssues, - onOpenHealthDialog: healthIndicator.openDialog, - }; + const healthProps = toHeaderHealthProps(healthIndicator); const sharedSearch = { searchQuery, onSearchChange, isSearchLoading }; const renderHeader = () => { diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 02fc438144..fe4a7eaad5 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -26,7 +26,7 @@ import { type TasksListDisplayOptions, } from "./mobile-menu-task-list-options"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; -import { linkToTask, linkToTasks } from "@/lib/links"; +import { linkToTask, linkToTaskOverview, linkToTasks } from "@/lib/links"; import { cn } from "@/lib/utils"; import type { Repository, Task } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; @@ -518,11 +518,13 @@ export function MobileMenuSheet({ onOpenChange(false); } else if (value === "kanban") { onViewModeChange("kanban"); - if (currentPage !== "kanban") router.push("/"); + if (currentPage !== "kanban") + router.push(linkToTaskOverview({ workspaceId, workflowId: activeWorkflowId ?? undefined })); onOpenChange(false); } else if (value === "pipeline" && !isMobile) { onViewModeChange("pipeline"); - if (currentPage !== "kanban") router.push("/"); + if (currentPage !== "kanban") + router.push(linkToTaskOverview({ workspaceId, workflowId: activeWorkflowId ?? undefined })); onOpenChange(false); } }; diff --git a/apps/web/components/page-topbar.test.tsx b/apps/web/components/page-topbar.test.tsx index 9f2afc39d2..4324068c80 100644 --- a/apps/web/components/page-topbar.test.tsx +++ b/apps/web/components/page-topbar.test.tsx @@ -18,7 +18,7 @@ describe("PageTopbar phone home crumb", () => { expect(home).not.toBeNull(); // Hidden from md up, where the always-visible AppSidebar owns Home. expect(home.className).toContain("md:hidden"); - expect(home.querySelector("a")?.getAttribute("href")).toBe("/"); + expect(home.querySelector("a")?.getAttribute("href")).toBe("/?home=overview"); }); it("omits it when the page supplies its own leading nav", () => { diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx index e88e85613b..f64f6189ae 100644 --- a/apps/web/components/page-topbar.tsx +++ b/apps/web/components/page-topbar.tsx @@ -12,6 +12,7 @@ import { } from "@kandev/ui/breadcrumb"; import { cn } from "@kandev/ui/lib/utils"; import { AppStatusDrawerTrigger } from "@/components/app-status-bar/app-status-surface-provider"; +import { linkToTaskOverview } from "@/lib/links"; type PageTopbarProps = { /** Page title shown as the rightmost (current) breadcrumb */ @@ -46,8 +47,16 @@ type PageTopbarProps = { showStatusTrigger?: boolean; }; -function BackLink({ href, label }: { href: string; label: string }) { - if (href === "/") { +function BackLink({ + href, + label, + isHome = href === "/", +}: { + href: string; + label: string; + isHome?: boolean; +}) { + if (isHome) { return ( - + diff --git a/apps/web/components/settings/general-nav.ts b/apps/web/components/settings/general-nav.ts index 61aa7cfe85..a617b88e9c 100644 --- a/apps/web/components/settings/general-nav.ts +++ b/apps/web/components/settings/general-nav.ts @@ -20,7 +20,7 @@ export const GENERAL_NAV_ITEMS: GeneralNavItem[] = [ { href: "/settings/general/appearance", label: "Appearance", - description: "Theme, metrics, and changes panel preferences", + description: "Theme, startup page, metrics, and changes panel preferences", icon: IconPalette, }, { diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 7bd6f0d271..348da2c7b3 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -11,6 +11,7 @@ import { IconGitBranch, IconArchive, IconArrowBackUp, + IconHome, } from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; @@ -33,6 +34,7 @@ import { useSettingsSaveContributor } from "@/components/settings/settings-save- import type { StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; import { buildPluginShortcutEntries } from "@/lib/keyboard/plugin-shortcuts"; import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; +import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card"; function ThemeSettingsCard({ theme, @@ -145,16 +147,66 @@ function ChangesPanelLayoutCard({ function createAppearanceSavedState( theme: Theme, - userSettings: Pick, + userSettings: Pick< + UserSettingsState, + "changesPanelLayout" | "startupPage" | "systemMetricsDisplay" + >, ) { return { theme, changesPanelLayout: userSettings.changesPanelLayout, + startupPage: userSettings.startupPage, showMetrics: userSettings.systemMetricsDisplay.showInTopbar, simplifiedMetrics: userSettings.systemMetricsDisplay.simplified, }; } +function StartupPageSettingsSection({ + value, + isDirty, + onChange, +}: { + value: UserSettingsState["startupPage"]; + isDirty: boolean; + onChange: (startupPage: UserSettingsState["startupPage"]) => void; +}) { + return ( + <> + + + } + title="Startup Page" + description="Choose what opens when Kandev starts or you visit its bare home page" + > + + + + + + ); +} + +function AppearanceThemeSection({ + theme, + isDirty, + onChange, +}: { + theme: Theme; + isDirty: boolean; + onChange: (theme: Theme) => void; +}) { + return ( + } + title="Appearance" + description="Customize how the application looks" + > + + + ); +} + export function GeneralSettings() { return (
@@ -230,6 +282,7 @@ export function AppearanceSettings() { await updateUserSettings({ workspace_id: current.workspaceId || "", repository_ids: current.repositoryIds || [], + startup_page: submitted.startupPage, changes_panel_layout: submitted.changesPanelLayout, system_metrics_display: { show_in_topbar: submitted.showMetrics, @@ -244,6 +297,7 @@ export function AppearanceSettings() { setUserSettings({ ...storeApi.getState().userSettings, changesPanelLayout: submitted.changesPanelLayout, + startupPage: submitted.startupPage, systemMetricsDisplay: { showInTopbar: submitted.showMetrics, simplified: submitted.simplifiedMetrics, @@ -263,22 +317,20 @@ export function AppearanceSettings() { return (
- } - title="Appearance" - description="Customize how the application looks" - > - { - updateDraft({ theme }); - previewTheme(theme); - }} - /> - + { + updateDraft({ theme }); + previewTheme(theme); + }} + /> - + updateDraft({ startupPage })} + /> } diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 4bc34799a3..dc73e79f2d 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -16,6 +16,7 @@ import { integrationFromPathname } from "@/components/integrations/integration-c import { safeDecodePathSegment } from "@/lib/routing/path"; import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item"; +import { linkToTaskOverview } from "@/lib/links"; import { cn } from "@/lib/utils"; // Brand/initialism overrides so the derived label matches how the rest of the @@ -222,7 +223,7 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) { )} setOpen(false)} > diff --git a/apps/web/components/settings/startup-page-settings-card.tsx b/apps/web/components/settings/startup-page-settings-card.tsx new file mode 100644 index 0000000000..5efc369167 --- /dev/null +++ b/apps/web/components/settings/startup-page-settings-card.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Label } from "@kandev/ui/label"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import type { StartupPage } from "@/lib/types/http"; +import { SettingsCard } from "./settings-card"; + +const OPTIONS: Array<{ value: StartupPage; label: string; description: string }> = [ + { + value: "task_overview", + label: "Task overview", + description: "Start on your saved Kanban, Pipeline, or List view.", + }, + { + value: "last_task", + label: "Last visited task", + description: "Resume the most recently opened task in this workspace on this device.", + }, +]; + +export function StartupPageSettingsCard({ + value, + isDirty, + onChange, +}: { + value: StartupPage; + isDirty: boolean; + onChange: (value: StartupPage) => void; +}) { + return ( + + + Open Kandev to + + This applies when Kandev starts or you open its bare home page. Home, Back, and workflow + navigation always open the task overview. + + + + onChange(next as StartupPage)} + data-settings-dirty={isDirty} + className="gap-3" + > + {OPTIONS.map((option) => { + const labelId = `startup-page-${option.value}-label`; + const descriptionId = `startup-page-${option.value}-description`; + const selected = value === option.value; + return ( + + ); + })} + + + + ); +} diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx index a1d2c6af6e..458093d92f 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -17,6 +17,7 @@ import { } from "./session-mobile-top-bar-git-controls"; import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions"; import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button"; +import { linkToTaskOverview } from "@/lib/links"; type SessionMobileTopBarProps = { taskId?: string | null; @@ -319,7 +320,10 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 7fcac6b2ad..fffd9a0075 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -195,6 +195,8 @@ export const test = backendFixture.extend< // test renders cards with data-testid="pipeline-task-" instead of // "task-card-", breaking taskCardByTitle locators. kanban_view_mode: "", + // Keep startup routing deterministic for tests that open bare home. + startup_page: "task_overview", // Reset to the default (off). Anchored-bar tests flip this via // saveUserSettings; without this reset it would leak into unrelated // tests running later in the same worker. @@ -290,6 +292,7 @@ test.beforeEach(async ({ apiClient, seedData }) => { sidebar_views: [], saved_layouts: [], kanban_view_mode: "", + startup_page: "task_overview", show_anchored_prompt_bar: false, show_scroll_to_last_prompt: true, show_scroll_to_start: false, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 4232b776b7..01612f0f94 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -831,6 +831,7 @@ export class ApiClient { terminal_link_behavior?: string; terminal_font_family?: string; terminal_font_size?: number; + startup_page?: "task_overview" | "last_task"; mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault; tasks_list_show_details?: boolean; show_transcript_auto_scroll_control?: boolean; @@ -855,6 +856,7 @@ export class ApiClient { terminal_link_behavior?: "new_tab" | "browser_panel"; terminal_font_family?: string; terminal_font_size?: number; + startup_page?: "task_overview" | "last_task"; keyboard_shortcuts?: Record; default_utility_agent_id?: string; default_utility_model?: string; diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts index 69d1a4b524..cfa8d3351f 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts @@ -83,7 +83,10 @@ test.describe("Quick Chat entry points on mobile", () => { await testPage.waitForLoadState("networkidle"); const homeLink = testPage.getByRole("link", { name: "Kandev home" }); - await expect(homeLink).toHaveAttribute("href", `/?workspaceId=${seedData.workspaceId}`); + await expect(homeLink).toHaveAttribute( + "href", + `/?home=overview&workspaceId=${seedData.workspaceId}`, + ); await homeLink.click(); diff --git a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts index 2b516d17d7..ceb69a0349 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -365,7 +365,12 @@ test.describe("Mobile kanban view", () => { const menuCard = testPage.getByTestId("mobile-home-menu-card"); await menuCard.getByText("Kanban", { exact: true }).click(); - await expect(testPage).toHaveURL(/\/$/); + await expect(testPage).toHaveURL( + (url) => + url.pathname === "/" && + url.searchParams.get("home") === "overview" && + url.searchParams.get("workspaceId") === seedData.workspaceId, + ); await expect(testPage.getByTestId("mobile-kanban-layout")).toBeVisible(); await expect .poll(async () => (await apiClient.getUserSettings()).settings.kanban_view_mode) diff --git a/apps/web/e2e/tests/office/sidebar-navigation.spec.ts b/apps/web/e2e/tests/office/sidebar-navigation.spec.ts index 9c4c17740a..761e54e158 100644 --- a/apps/web/e2e/tests/office/sidebar-navigation.spec.ts +++ b/apps/web/e2e/tests/office/sidebar-navigation.spec.ts @@ -102,8 +102,17 @@ test.describe("Sidebar Home destination", () => { await testPage.goto("/stats"); const home = testPage.getByRole("link", { name: "Home", exact: true }); await expect(home).toBeVisible({ timeout: 15_000 }); + await expect(home).toHaveAttribute("href", /^\/\?home=overview&workspaceId=.+$/); + const homeHref = await home.getAttribute("href"); + const workspaceId = new URL(homeHref ?? "/", testPage.url()).searchParams.get("workspaceId"); + expect(workspaceId).toBeTruthy(); await home.click(); - await expect(testPage).toHaveURL(/\/$/); + await expect(testPage).toHaveURL( + (url) => + url.pathname === "/" && + url.searchParams.get("home") === "overview" && + url.searchParams.get("workspaceId") === workspaceId, + ); await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 15_000 }); }); }); diff --git a/apps/web/e2e/tests/office/sidebar-workspace-mode.spec.ts b/apps/web/e2e/tests/office/sidebar-workspace-mode.spec.ts index 2b2a8a4bf3..dcc944b74a 100644 --- a/apps/web/e2e/tests/office/sidebar-workspace-mode.spec.ts +++ b/apps/web/e2e/tests/office/sidebar-workspace-mode.spec.ts @@ -20,13 +20,16 @@ test.describe("Sidebar workspace mode navigation", () => { await testPage.getByTestId("sidebar-workspace-trigger").click(); await testPage.getByTestId(`sidebar-workspace-item-${kanbanWorkspace.id}`).click(); await expect(testPage).toHaveURL( - (url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id, + (url) => + url.pathname === "/" && + url.searchParams.get("home") === "overview" && + url.searchParams.get("workspaceId") === kanbanWorkspace.id, { timeout: 10_000 }, ); await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute( "href", - `/?workspaceId=${kanbanWorkspace.id}`, + `/?home=overview&workspaceId=${kanbanWorkspace.id}`, ); await expect(sidebar.root.getByRole("button", { name: "Office" })).toBeVisible(); await sidebar.root.getByRole("button", { name: "Office" }).click(); @@ -46,12 +49,15 @@ test.describe("Sidebar workspace mode navigation", () => { await sidebar.root.getByRole("button", { name: "Kanban" }).click(); await expect(testPage).toHaveURL( - (url) => url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id, + (url) => + url.pathname === "/" && + url.searchParams.get("home") === "overview" && + url.searchParams.get("workspaceId") === kanbanWorkspace.id, { timeout: 10_000 }, ); await expect(sidebar.root.getByRole("link", { name: "Kandev home" })).toHaveAttribute( "href", - `/?workspaceId=${kanbanWorkspace.id}`, + `/?home=overview&workspaceId=${kanbanWorkspace.id}`, ); }); diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts index 6b36722862..cbd7b791fb 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts @@ -97,7 +97,7 @@ test.describe("Mobile plugin navigation", () => { const home = testPage.getByTestId("topbar-phone-home"); await expect(home).toBeVisible(); await home.click(); - await expect(testPage).toHaveURL(/\/$/); + await expect(testPage).toHaveURL(/\/\?home=overview$/); // Desktop parity reference: the same item in the always-visible sidebar. const desktop = await openDesktopClient(browser, backend.frontendUrl, backend.port); diff --git a/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts new file mode 100644 index 0000000000..d3d8f691f1 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from "../../fixtures/test-base"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; + +const APPEARANCE_PATH = "/settings/general/appearance"; + +test.describe("Mobile startup page", () => { + test("uses a touch-friendly saved preference, resumes the task, and returns to overview", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await testPage.setViewportSize({ width: 390, height: 844 }); + const task = await apiClient.createTask(seedData.workspaceId, "Mobile Startup Resume Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + try { + await testPage.goto(`/t/${task.id}`); + await expect( + testPage.locator("header").getByText("Mobile Startup Resume Task", { exact: true }), + ).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll( + () => + testPage.evaluate( + ({ taskId, workspaceId }) => { + const entries = JSON.parse( + window.localStorage.getItem("kandev.recentTasks.v1") ?? "[]", + ) as Array<{ taskId?: string; workspaceId?: string }>; + return entries.some( + (entry) => entry.taskId === taskId && entry.workspaceId === workspaceId, + ); + }, + { taskId: task.id, workspaceId: seedData.workspaceId }, + ), + { timeout: 15_000 }, + ) + .toBe(true); + + await testPage.goto(APPEARANCE_PATH); + const lastTaskRow = testPage.locator('label[for="startup-page-last_task"]'); + const lastTaskRadio = testPage.getByRole("radio", { name: "Last visited task" }); + await expect(lastTaskRow).toBeVisible({ timeout: 15_000 }); + const rowBox = await lastTaskRow.boundingBox(); + expect(rowBox).not.toBeNull(); + expect(rowBox!.height).toBeGreaterThanOrEqual(44); + await lastTaskRow.tap(); + await expect(lastTaskRadio).toBeChecked(); + + const floatingSave = testPage.getByTestId("settings-floating-save"); + await floatingSave.getByRole("button", { name: "Save changes" }).tap(); + await expect(floatingSave).not.toBeVisible({ timeout: 15_000 }); + await testPage.reload(); + await expect(lastTaskRadio).toBeChecked({ timeout: 15_000 }); + + await testPage.goto("/"); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}$`), { timeout: 15_000 }); + + await testPage.getByRole("link", { name: "Task overview" }).tap(); + const mobile = new MobileKanbanPage(testPage); + await expect(testPage).toHaveURL( + (url) => url.pathname === "/" && url.searchParams.get("home") === "overview", + ); + await expect(mobile.mobileKanbanLayout()).toBeVisible({ timeout: 15_000 }); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + } finally { + await apiClient.saveUserSettings({ startup_page: "task_overview" }); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/startup-page.spec.ts b/apps/web/e2e/tests/settings/startup-page.spec.ts new file mode 100644 index 0000000000..391aedfe22 --- /dev/null +++ b/apps/web/e2e/tests/settings/startup-page.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from "../../fixtures/test-base"; + +const APPEARANCE_PATH = "/settings/general/appearance"; + +test.describe("Startup page", () => { + test("saves the last-task choice, resumes it at bare home, and keeps Home on the overview", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const task = await apiClient.createTask(seedData.workspaceId, "Startup Page Resume Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + try { + await testPage.goto(`/t/${task.id}`); + await expect( + testPage.getByText("Startup Page Resume Task", { exact: true }).first(), + ).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll( + () => + testPage.evaluate( + ({ taskId, workspaceId }) => { + const entries = JSON.parse( + window.localStorage.getItem("kandev.recentTasks.v1") ?? "[]", + ) as Array<{ taskId?: string; workspaceId?: string }>; + return entries.some( + (entry) => entry.taskId === taskId && entry.workspaceId === workspaceId, + ); + }, + { taskId: task.id, workspaceId: seedData.workspaceId }, + ), + { timeout: 15_000 }, + ) + .toBe(true); + + await testPage.goto(APPEARANCE_PATH); + const lastTaskRadio = testPage.getByRole("radio", { name: "Last visited task" }); + await expect(lastTaskRadio).toBeVisible({ timeout: 15_000 }); + await lastTaskRadio.click(); + + const card = testPage.getByTestId("startup-page-settings-card"); + await expect(card).toHaveAttribute("data-settings-dirty", "true"); + expect((await apiClient.getUserSettings()).settings.startup_page).toBe("task_overview"); + + const floatingSave = testPage.getByTestId("settings-floating-save"); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(floatingSave).not.toBeVisible({ timeout: 15_000 }); + expect((await apiClient.getUserSettings()).settings.startup_page).toBe("last_task"); + + await testPage.reload(); + await expect(lastTaskRadio).toBeChecked({ timeout: 15_000 }); + + await testPage.goto("/"); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}$`), { timeout: 15_000 }); + + await testPage.getByRole("link", { name: "Home", exact: true }).click(); + await expect(testPage).toHaveURL( + (url) => url.pathname === "/" && url.searchParams.get("home") === "overview", + ); + await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 15_000 }); + } finally { + await apiClient.saveUserSettings({ startup_page: "task_overview" }); + } + }); + + test("falls back to the task overview when this device has no task in the active workspace", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(90_000); + try { + await apiClient.saveUserSettings({ startup_page: "last_task" }); + await testPage.goto("/?home=overview"); + await testPage.evaluate(() => { + window.localStorage.setItem( + "kandev.recentTasks.v1", + JSON.stringify([ + { + taskId: "other-workspace-task", + title: "Other workspace task", + visitedAt: "2026-07-31T12:00:00.000Z", + workspaceId: "other-workspace", + }, + ]), + ); + }); + + await testPage.goto("/"); + await expect(testPage).not.toHaveURL(/\/t\//); + await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 15_000 }); + } finally { + await apiClient.saveUserSettings({ startup_page: "task_overview" }); + await testPage.evaluate(() => window.localStorage.removeItem("kandev.recentTasks.v1")); + } + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts b/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts index 0f8e573ad0..441eb0916d 100644 --- a/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts +++ b/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts @@ -35,7 +35,7 @@ test("mobile creates in the selected workspace after leaving an open task", asyn // The phone task workbench has no workspace picker. Its existing back action // returns to the board, whose responsive Menu drawer owns workspace switching. - await testPage.locator('header a[href="/"]').tap(); + await testPage.getByRole("link", { name: "Task overview" }).tap(); await expect(mobile.mobileKanbanLayout()).toBeVisible(); await mobile.mobileMenuButton.tap(); await testPage.getByTestId("mobile-workspace-trigger").tap(); diff --git a/apps/web/hooks/domains/kanban/use-kanban-actions.ts b/apps/web/hooks/domains/kanban/use-kanban-actions.ts index 924dbad124..e70d2ed613 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-actions.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-actions.ts @@ -6,6 +6,7 @@ import { useAppStoreApi } from "@/components/state-provider"; import { useTaskCRUD } from "@/hooks/use-task-crud"; import type { Task as BackendTask } from "@/lib/types/http"; import type { KanbanState, WorkspaceState, WorkflowsState } from "@/lib/state/slices"; +import { linkToTaskOverview } from "@/lib/links"; type UseKanbanActionsOptions = { workspaceState: WorkspaceState; @@ -20,13 +21,10 @@ function isTaskDetailPath(): boolean { } function kanbanWorkspaceHref(workspaceId: string): string { - if (typeof window === "undefined") return `/?workspaceId=${workspaceId}`; + if (typeof window === "undefined") return linkToTaskOverview({ workspaceId }); const current = new URLSearchParams(window.location.search); - const next = new URLSearchParams(); const workflowId = current.get("workflowId"); - if (workflowId) next.set("workflowId", workflowId); - next.set("workspaceId", workspaceId); - return `/?${next.toString()}`; + return linkToTaskOverview({ workspaceId, workflowId: workflowId ?? undefined }); } /** Handle creating a new task in the kanban board, merging with any WS-provided data. */ @@ -142,7 +140,7 @@ export function useKanbanActions({ workspaceState, workflowsState }: UseKanbanAc if (nextWorkspaceId) { router.push(kanbanWorkspaceHref(nextWorkspaceId)); } else { - router.push("/"); + router.push(linkToTaskOverview()); } }, [router, store, workspaceState.activeId], @@ -162,8 +160,9 @@ export function useKanbanActions({ workspaceState, workflowsState }: UseKanbanAc const workspaceId = workflowsState.items.find( (workflow: WorkflowsState["items"][number]) => workflow.id === nextWorkflowId, )?.workspaceId; - const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ""; - router.push(`/?workflowId=${nextWorkflowId}${workspaceParam}`); + router.push( + linkToTaskOverview({ workspaceId: workspaceId ?? undefined, workflowId: nextWorkflowId }), + ); } }, [router, store, workflowsState.activeId, workflowsState.items], diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 2d36f7d548..9507697669 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -49,6 +49,7 @@ function makeUnloadedSettings(): UserSettingsState { workspaceId: null, workflowId: null, kanbanViewMode: null, + startupPage: "task_overview", repositoryIds: [], tasksListSort: "updated_desc", tasksListGroup: "state", diff --git a/apps/web/hooks/use-kanban-display-settings.test.ts b/apps/web/hooks/use-kanban-display-settings.test.ts new file mode 100644 index 0000000000..cda192b159 --- /dev/null +++ b/apps/web/hooks/use-kanban-display-settings.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const workspaceId = "workspace-1"; + const workflowId = "workflow-1"; + return { + workspaceId, + workflowId, + activeWorkspaceId: workspaceId as string | null, + activeWorkflowId: workflowId as string | null, + setActiveWorkspace: vi.fn(), + setActiveWorkflow: vi.fn(), + commitSettings: vi.fn(), + setView: vi.fn(), + workflows: [ + { id: workflowId, workspaceId }, + { id: "workflow-2", workspaceId: "workspace-2" }, + ], + }; +}); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + workspaces: { items: [], activeId: mocks.activeWorkspaceId }, + workflows: { items: mocks.workflows, activeId: mocks.activeWorkflowId }, + setActiveWorkspace: mocks.setActiveWorkspace, + setActiveWorkflow: mocks.setActiveWorkflow, + userSettings: { enablePreviewOnClick: false }, + }), +})); +vi.mock("@/hooks/use-task-listing-view", () => ({ + useTaskListingView: () => ({ effectiveView: "kanban", setView: mocks.setView }), +})); +vi.mock("@/hooks/use-user-display-settings", () => ({ + useUserDisplaySettings: () => ({ + settings: { + workspaceId: mocks.workspaceId, + workflowId: mocks.workflowId, + repositoryIds: [], + tasksListShowDetails: false, + }, + commitSettings: mocks.commitSettings, + repositories: [], + repositoriesLoading: false, + allRepositoriesSelected: true, + }), +})); + +import { useKanbanDisplaySettings } from "./use-kanban-display-settings"; + +function resetMocks() { + mocks.activeWorkspaceId = mocks.workspaceId; + mocks.activeWorkflowId = mocks.workflowId; + mocks.setActiveWorkspace.mockReset(); + mocks.setActiveWorkflow.mockReset(); + mocks.setActiveWorkspace.mockImplementation((workspaceId: string | null) => { + mocks.activeWorkspaceId = workspaceId; + }); + mocks.setActiveWorkflow.mockImplementation((workflowId: string | null) => { + mocks.activeWorkflowId = workflowId; + }); + mocks.commitSettings.mockReset(); + mocks.setView.mockReset(); + window.history.replaceState({}, "", "/"); +} + +beforeEach(resetMocks); +afterEach(resetMocks); + +describe("useKanbanDisplaySettings", () => { + it("keeps workspace and workflow scope in task overview history", () => { + const { result, rerender } = renderHook(() => useKanbanDisplaySettings()); + + act(() => result.current.onWorkspaceChange("workspace-2")); + expect(window.location.search).toBe("?home=overview&workspaceId=workspace-2"); + rerender(); + + act(() => result.current.onWorkflowChange("workflow-2")); + expect(window.location.search).toBe( + "?home=overview&workspaceId=workspace-2&workflowId=workflow-2", + ); + rerender(); + + act(() => result.current.onWorkflowChange(null)); + expect(window.location.search).toBe("?home=overview&workspaceId=workspace-2"); + }); +}); diff --git a/apps/web/hooks/use-kanban-display-settings.ts b/apps/web/hooks/use-kanban-display-settings.ts index 6582cef584..7defbfb2c4 100644 --- a/apps/web/hooks/use-kanban-display-settings.ts +++ b/apps/web/hooks/use-kanban-display-settings.ts @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { useAppStore } from "@/components/state-provider"; import { useUserDisplaySettings } from "@/hooks/use-user-display-settings"; import { useTaskListingView } from "@/hooks/use-task-listing-view"; +import { linkToTaskOverview } from "@/lib/links"; import type { WorkflowsState } from "@/lib/state/slices"; type UserSettingsFields = { @@ -36,6 +37,10 @@ function useViewModeChange() { return { effectiveView, onViewModeChange }; } +function replaceTaskOverviewHistory(workspaceId?: string, workflowId?: string) { + window.history.pushState({}, "", linkToTaskOverview({ workspaceId, workflowId })); +} + /** * Custom hook that consolidates all kanban display settings and eliminates prop drilling. * This hook provides access to workspaces, workflows, repositories, and preview settings, @@ -68,8 +73,7 @@ export function useKanbanDisplaySettings() { const handleWorkspaceChange = useCallback( (nextWorkspaceId: string | null) => { setActiveWorkspace(nextWorkspaceId); - const url = nextWorkspaceId ? `/?workspaceId=${nextWorkspaceId}` : "/"; - window.history.pushState({}, "", url); + replaceTaskOverviewHistory(nextWorkspaceId ?? undefined); commitSettings({ workspaceId: nextWorkspaceId, workflowId: null, @@ -86,10 +90,9 @@ export function useKanbanDisplaySettings() { const workspaceId = workflows.find( (workflow: WorkflowsState["items"][number]) => workflow.id === nextWorkflowId, )?.workspaceId; - const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ""; - window.history.pushState({}, "", `/?workflowId=${nextWorkflowId}${workspaceParam}`); + replaceTaskOverviewHistory(workspaceId, nextWorkflowId); } else if (activeWorkspaceId) { - window.history.pushState({}, "", `/?workspaceId=${activeWorkspaceId}`); + replaceTaskOverviewHistory(activeWorkspaceId); } commitSettings({ workspaceId: userSettings.workspaceId, diff --git a/apps/web/hooks/use-task-removal.test.ts b/apps/web/hooks/use-task-removal.test.ts index 67c1cb37f8..60fe4107c1 100644 --- a/apps/web/hooks/use-task-removal.test.ts +++ b/apps/web/hooks/use-task-removal.test.ts @@ -8,6 +8,7 @@ const listTaskSessionsMock = vi.fn(); const fetchTaskMock = vi.fn(); vi.mock("@/lib/links", () => ({ + linkToTaskOverview: () => "/?home=overview", replaceTaskUrl: (...args: unknown[]) => replaceTaskUrlMock(...args), })); @@ -254,7 +255,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { expect(replaceTaskUrlMock).not.toHaveBeenCalled(); }); - it("redirects to / when no remaining tasks AND user still on removed task", async () => { + it("redirects to the explicit overview when no remaining tasks AND user is still on removed task", async () => { const store = makeStore({ activeTaskId: "task-A", activeSessionId: "sess-A", @@ -283,7 +284,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { wasActiveSessionId: "sess-A", }); expect(removeResult.switchedTaskId).toBeNull(); - expect(hrefSetter).toHaveBeenCalledWith("/"); + expect(hrefSetter).toHaveBeenCalledWith("/?home=overview"); } finally { Object.defineProperty(window, "location", { configurable: true, @@ -330,7 +331,7 @@ describe("useTaskRemoval — next task selection", () => { expect(removal.switchedTaskId).toBeNull(); expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled(); expect(replaceTaskUrlMock).not.toHaveBeenCalled(); - expect(hrefSetter).toHaveBeenCalledWith("/"); + expect(hrefSetter).toHaveBeenCalledWith("/?home=overview"); } finally { Object.defineProperty(window, "location", { configurable: true, diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts index d035dadf8d..cb9e1056af 100644 --- a/apps/web/hooks/use-task-removal.ts +++ b/apps/web/hooks/use-task-removal.ts @@ -3,7 +3,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { KanbanState } from "@/lib/state/slices"; import type { TaskSession } from "@/lib/types/http"; -import { replaceTaskUrl } from "@/lib/links"; +import { linkToTaskOverview, replaceTaskUrl } from "@/lib/links"; import { fetchTask, listTaskSessions } from "@/lib/api"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; import { getRecentTasks } from "@/lib/recent-tasks"; @@ -273,7 +273,7 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp return { switchedTaskId: nextTask.id }; } - window.location.href = "/"; + window.location.href = linkToTaskOverview(); return { switchedTaskId: null }; }, [store, useLayoutSwitch, loadTaskSessionsForTask], diff --git a/apps/web/lib/links.test.ts b/apps/web/lib/links.test.ts index e1d65ab5cc..37130bedfd 100644 --- a/apps/web/lib/links.test.ts +++ b/apps/web/lib/links.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isTaskDetailPath, linkToTask, + linkToTaskOverview, linkToTasks, normalizePathname, replaceTaskUrl, @@ -22,6 +23,16 @@ describe("task links", () => { expect(linkToTasks("workspace-123")).toBe("/tasks?workspace=workspace-123"); }); + it("marks deliberate overview navigation so startup routing does not resume a task", () => { + expect(linkToTaskOverview()).toBe("/?home=overview"); + expect(linkToTaskOverview({ workspaceId: "workspace 123" })).toBe( + "/?home=overview&workspaceId=workspace+123", + ); + expect(linkToTaskOverview({ workflowId: "workflow-123" })).toBe( + "/?home=overview&workflowId=workflow-123", + ); + }); + it("replaces the browser URL with the canonical task detail route", () => { const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {}); diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts index 7646a5092e..f0019ecd15 100644 --- a/apps/web/lib/links.ts +++ b/apps/web/lib/links.ts @@ -3,6 +3,19 @@ export function linkToTask(taskId: string, layout?: string): string { return layout ? `${base}?layout=${encodeURIComponent(layout)}` : base; } +export function linkToTaskOverview({ + workspaceId, + workflowId, +}: { + workspaceId?: string; + workflowId?: string; +} = {}): string { + const params = new URLSearchParams({ home: "overview" }); + if (workspaceId) params.set("workspaceId", workspaceId); + if (workflowId) params.set("workflowId", workflowId); + return `/?${params.toString()}`; +} + /** Task-detail route prefixes the SPA serves: canonical and compatibility. */ const TASK_DETAIL_PREFIXES = ["/t/", "/tasks/"]; diff --git a/apps/web/lib/recent-tasks.test.ts b/apps/web/lib/recent-tasks.test.ts index edc8b05f9d..bf7fc03d70 100644 --- a/apps/web/lib/recent-tasks.test.ts +++ b/apps/web/lib/recent-tasks.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getRecentTasks, + findMostRecentTaskForWorkspace, MAX_RECENT_TASKS, RECENT_TASKS_STORAGE_KEY, removeRecentTask, @@ -64,4 +65,16 @@ describe("recent task storage", () => { expect(result.map((item) => item.taskId)).toEqual(["2"]); expect(getRecentTasks().map((item) => item.taskId)).toEqual(["2"]); }); + + it("selects the newest task only from the active workspace", () => { + const missingWorkspace = { ...entry("legacy"), workspaceId: null }; + const entries = [ + entry("foreign"), + missingWorkspace, + { ...entry("local"), workspaceId: "workspace-2" }, + ]; + + expect(findMostRecentTaskForWorkspace(entries, "workspace-2")?.taskId).toBe("local"); + expect(findMostRecentTaskForWorkspace(entries, "workspace-3")).toBeNull(); + }); }); diff --git a/apps/web/lib/recent-tasks.ts b/apps/web/lib/recent-tasks.ts index f9adb14ba3..b595c84862 100644 --- a/apps/web/lib/recent-tasks.ts +++ b/apps/web/lib/recent-tasks.ts @@ -78,6 +78,14 @@ export function getRecentTasks(): RecentTaskEntry[] { } } +export function findMostRecentTaskForWorkspace( + entries: RecentTaskEntry[], + workspaceId: string | undefined, +): RecentTaskEntry | null { + if (!workspaceId) return null; + return entries.find((entry) => entry.workspaceId === workspaceId) ?? null; +} + export function setRecentTasks(entries: RecentTaskEntry[]): RecentTaskEntry[] { const normalized = normalizeEntries(entries); if (typeof window === "undefined") return normalized; diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 073b3eec25..4d91453563 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -3,6 +3,7 @@ import { buildCoreFields, mapUserSettingsResponse, parseChangesPanelLayout, + parseStartupPage, parseSystemMetricsDisplay, parseVoiceMode, } from "./user-settings"; @@ -11,6 +12,30 @@ import { workspaceId as toWorkspaceId } from "@/lib/types/ids"; const UPDATED_AT = "2026-01-01T00:00:00Z"; const DEFAULT_USER_ID = "default-user"; +describe("startup page user settings", () => { + it("normalizes startup page preferences", () => { + expect(parseStartupPage("last_task")).toBe("last_task"); + expect(parseStartupPage(undefined)).toBe("task_overview"); + expect(parseStartupPage("future_value")).toBe("task_overview"); + }); + + it("defaults startup page and maps the last-task choice", () => { + expect(mapUserSettingsResponse(null).startupPage).toBe("task_overview"); + + const result = mapUserSettingsResponse({ + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + startup_page: "last_task", + updated_at: UPDATED_AT, + }, + }); + + expect(result.startupPage).toBe("last_task"); + }); +}); + describe("buildCoreFields", () => { it("normalizes the simplified metrics preference and defaults old rows to detailed", () => { expect(parseSystemMetricsDisplay({ show_in_topbar: true, simplified: true } as never)).toEqual({ diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index aa52719055..9f5c8da9f5 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -12,7 +12,7 @@ import { type VoiceModeState, } from "@/lib/state/slices/settings/types"; import type { SidebarTaskPrefsApi, UserSettings, UserSettingsResponse } from "@/lib/types/http"; -import type { MCPTaskAgentProfileDefault } from "@/lib/types/http-user-settings"; +import type { MCPTaskAgentProfileDefault, StartupPage } from "@/lib/types/http-user-settings"; import type { VoiceModeSettings } from "@/lib/types/http-voice"; export type UserSettingsData = Omit, "workspace_id"> & { @@ -24,6 +24,7 @@ export function createDefaultUserSettings(): UserSettingsState { workspaceId: null, workflowId: null, kanbanViewMode: null, + startupPage: "task_overview", repositoryIds: [], tasksListSort: DEFAULT_TASKS_LIST_SORT, tasksListGroup: DEFAULT_TASKS_LIST_GROUP, @@ -90,6 +91,10 @@ export function parseMCPTaskAgentProfileDefault( return value === "workspace_default" ? "workspace_default" : "current_task"; } +export function parseStartupPage(value: string | undefined): StartupPage { + return value === "last_task" ? "last_task" : "task_overview"; +} + export function parseSystemMetricsDisplay(value: UserSettingsData["system_metrics_display"]) { return { showInTopbar: value?.show_in_topbar ?? false, @@ -228,6 +233,7 @@ function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) { current.mcpTaskAgentProfileDefault, parseMCPTaskAgentProfileDefault, ), + startupPage: mapDefined(s.startup_page, current.startupPage, parseStartupPage), showAnchoredPromptBar: s.show_anchored_prompt_bar ?? current.showAnchoredPromptBar, showScrollToLastPrompt: s.show_scroll_to_last_prompt ?? current.showScrollToLastPrompt, showScrollToStart: s.show_scroll_to_start ?? current.showScrollToStart, diff --git a/apps/web/lib/startup-page.test.ts b/apps/web/lib/startup-page.test.ts new file mode 100644 index 0000000000..e3e7991c9d --- /dev/null +++ b/apps/web/lib/startup-page.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { RecentTaskEntry } from "./recent-tasks"; +import { isExplicitHomeDestination, resolveStartupTaskId } from "./startup-page"; + +const WORKSPACE_ID = "workspace-1"; + +function recentTask(taskId: string, workspaceId: string): RecentTaskEntry { + return { + taskId, + title: taskId, + visitedAt: "2026-07-31T10:00:00Z", + workspaceId, + }; +} + +describe("startup page resolution", () => { + it("resumes the newest recent task in the active workspace", () => { + expect( + resolveStartupTaskId({ + startupPage: "last_task", + workspaceId: WORKSPACE_ID, + recentTasks: [recentTask("newest", WORKSPACE_ID), recentTask("older", WORKSPACE_ID)], + hasExplicitDestination: false, + }), + ).toBe("newest"); + }); + + it("skips newer recent tasks from a different workspace", () => { + expect( + resolveStartupTaskId({ + startupPage: "last_task", + workspaceId: WORKSPACE_ID, + recentTasks: [recentTask("foreign", "workspace-2"), recentTask("local", WORKSPACE_ID)], + hasExplicitDestination: false, + }), + ).toBe("local"); + }); + + it("falls back when there is no eligible task or the overview was explicit", () => { + expect( + resolveStartupTaskId({ + startupPage: "last_task", + workspaceId: WORKSPACE_ID, + recentTasks: [recentTask("foreign", "workspace-2")], + hasExplicitDestination: false, + }), + ).toBeNull(); + + expect( + resolveStartupTaskId({ + startupPage: "last_task", + workspaceId: WORKSPACE_ID, + recentTasks: [recentTask("local", WORKSPACE_ID)], + hasExplicitDestination: true, + }), + ).toBeNull(); + }); + + it("recognizes explicit task, workflow, and overview intent", () => { + expect(isExplicitHomeDestination(new URLSearchParams("home=overview"))).toBe(true); + expect(isExplicitHomeDestination(new URLSearchParams("workflowId=workflow-1"))).toBe(true); + expect(isExplicitHomeDestination(new URLSearchParams("taskId=task-1"))).toBe(true); + expect(isExplicitHomeDestination(new URLSearchParams("workspaceId=workspace-1"))).toBe(false); + }); +}); diff --git a/apps/web/lib/startup-page.ts b/apps/web/lib/startup-page.ts new file mode 100644 index 0000000000..ee0d50a357 --- /dev/null +++ b/apps/web/lib/startup-page.ts @@ -0,0 +1,34 @@ +import { findMostRecentTaskForWorkspace, type RecentTaskEntry } from "./recent-tasks"; +import type { StartupPage } from "./types/http-user-settings"; + +type StartupTaskResolutionInput = { + startupPage: StartupPage; + workspaceId: string | undefined; + recentTasks: RecentTaskEntry[]; + hasExplicitDestination: boolean; +}; + +export function isExplicitHomeDestination( + searchParams: URLSearchParams, + initialTaskId?: string, + initialSessionId?: string, +): boolean { + return Boolean( + initialTaskId || + initialSessionId || + searchParams.get("taskId") || + searchParams.get("sessionId") || + searchParams.get("workflowId") || + searchParams.get("home") === "overview", + ); +} + +export function resolveStartupTaskId({ + startupPage, + workspaceId, + recentTasks, + hasExplicitDestination, +}: StartupTaskResolutionInput): string | null { + if (startupPage !== "last_task" || hasExplicitDestination) return null; + return findMostRecentTaskForWorkspace(recentTasks, workspaceId)?.taskId ?? null; +} diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index d2aba513d2..ac14dfcb49 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -11,6 +11,7 @@ import type { SavedLayout, ToolStatus, MCPTaskAgentProfileDefault, + StartupPage, } from "@/lib/types/http"; import type { VoiceInputActivationMode, @@ -168,6 +169,7 @@ export type SettingsDataState = { export type UserSettingsState = { workspaceId: string | null; kanbanViewMode: string | null; + startupPage: StartupPage; workflowId: string | null; repositoryIds: string[]; tasksListSort: TasksListSort; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 4d305f36da..4a84979f86 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -2,6 +2,7 @@ import type { WorkspaceId } from "./ids"; import type { VoiceModeSettings } from "./http-voice"; export type MCPTaskAgentProfileDefault = "current_task" | "workspace_default"; +export type StartupPage = "task_overview" | "last_task"; export type SavedLayout = { id: string; @@ -49,6 +50,7 @@ export type UserSettings = { user_id: string; workspace_id: WorkspaceId; kanban_view_mode?: string; + startup_page?: StartupPage; workflow_filter_id?: string; repository_ids: string[]; tasks_list_sort?: string; @@ -105,6 +107,7 @@ export type UserSettingsUpdatePayload = { workspace_id?: string; workflow_filter_id?: string; kanban_view_mode?: string; + startup_page?: StartupPage; repository_ids?: string[]; tasks_list_sort?: string; tasks_list_group?: string; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 88ef1dcd37..cdefb2e2bc 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -25,6 +25,7 @@ export type { TaskCreateLastUsedApi, AppStatusBarOrderApi, MCPTaskAgentProfileDefault, + StartupPage, UserSettings, UserSettingsResponse, UserSettingsUpdatePayload, diff --git a/apps/web/lib/ws/handlers/tasks-archive.test.ts b/apps/web/lib/ws/handlers/tasks-archive.test.ts index b075a80862..73040be447 100644 --- a/apps/web/lib/ws/handlers/tasks-archive.test.ts +++ b/apps/web/lib/ws/handlers/tasks-archive.test.ts @@ -182,6 +182,7 @@ describe("task.updated archive cleanup", () => { archiveTask(store); expect(window.location.pathname).toBe("/"); + expect(window.location.search).toBe("?home=overview"); }); it("does not redirect when a different task is archived", () => { diff --git a/apps/web/lib/ws/handlers/tasks.deleted.test.ts b/apps/web/lib/ws/handlers/tasks.deleted.test.ts index 3dede13e34..fec7d3fdd1 100644 --- a/apps/web/lib/ws/handlers/tasks.deleted.test.ts +++ b/apps/web/lib/ws/handlers/tasks.deleted.test.ts @@ -130,6 +130,7 @@ describe("task.deleted live notification + redirect", () => { ); expect(window.location.pathname).toBe("/"); + expect(window.location.search).toBe("?home=overview"); expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ taskId: "t1", title: REVIEW_TITLE, diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 56726f0971..af00f7cf70 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -10,7 +10,7 @@ import { toKanbanTask, type TaskLike } from "@/lib/kanban/map-task"; import { sessionId as toSessionId } from "@/lib/types/http"; import { mergeTaskRepositoryFields } from "@/lib/ws/handlers/task-repositories"; import { softNavigate } from "@/lib/routing/client-router"; -import { isTaskDetailPath, normalizePathname } from "@/lib/links"; +import { isTaskDetailPath, linkToTaskOverview, normalizePathname } from "@/lib/links"; import { clearPinnedSessionIfOverridden, shouldPreservePinnedSessionForTask, @@ -321,7 +321,7 @@ function clearDeletedTaskWalkthrough(state: AppState, taskId: string): AppState } function removedTaskRedirectHref(pathname: string, taskId: string): string | null { - if (isTaskDetailPath(pathname, taskId)) return "/"; + if (isTaskDetailPath(pathname, taskId)) return linkToTaskOverview(); const normalized = normalizePathname(pathname); return normalized === `/office/tasks/${taskId}` ? "/office/tasks" : null; } diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 66f33acaa0..e37780910a 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -24,6 +24,26 @@ function userSettingsMessage( }; } +describe("startup page websocket sync", () => { + it("applies startup page preferences and normalizes unknown values", () => { + const store = makeStore(); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + startup_page: "last_task", + } as unknown as Partial), + ); + expect(store.getState().userSettings.startupPage).toBe("last_task"); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + startup_page: "unexpected", + } as unknown as Partial), + ); + expect(store.getState().userSettings.startupPage).toBe("task_overview"); + }); +}); + describe("user settings websocket handler", () => { it("updates the List detail preference and preserves it when omitted", () => { const store = makeStore(); diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 993556ed62..56bc255378 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -172,6 +172,8 @@ foreground-generating Claude turns retain the coarse queueing behavior. On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Kandev remembers the last selected view in that browser on the current device. Phones offer **Kanban** and **List** only; a saved desktop Pipeline preference is kept but shown as Kanban on the phone. +Under **Settings → General → Appearance → Startup Page**, choose **Task overview** (the default) or **Last visited task**. The latter resumes the most recently opened task in the current workspace on that device when Kandev starts or you open bare Home. It does not change an explicit task or workflow link. Home navigation and a task's Back action always return to the task overview; when there is no matching local recent task, Kandev opens the overview instead. + - Search matches tasks without changing their state. - The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**. - In **List**, the display menu can enable **Show task details** to include available repository, description, pull-request, session, parent, review, and archive context in each row. This option is off by default and follows the user across devices. diff --git a/docs/specs/ui/task-listing-display-preferences.md b/docs/specs/ui/task-listing-display-preferences.md index 71c0981aab..db7b1f9490 100644 --- a/docs/specs/ui/task-listing-display-preferences.md +++ b/docs/specs/ui/task-listing-display-preferences.md @@ -40,6 +40,13 @@ optional richer row without losing the current compact default. - Rich rows preserve the existing primary row action: tapping or clicking the row opens the task, while archive/delete and interactive metadata controls do not trigger row navigation. +- **Settings → General → Appearance → Startup Page** offers **Task overview** + (the default) and **Last visited task**. The latter applies only when Kandev + starts or the user opens bare home; it resumes the newest task recorded for + the active workspace on that device. +- Home navigation and a task's Back action always open the task overview, even + when **Last visited task** is selected. An explicit task, session, workflow, + or overview URL also keeps its explicit destination. ## Data model @@ -69,18 +76,34 @@ The existing backend-owned user settings object adds: The frontend settings store exposes the same value as `tasksListShowDetails`. +### Startup preference and local task target + +The existing backend-owned user settings object also adds: + +| Field | Type | Default | Meaning | +|---|---|---:|---| +| `startup_page` | string | `"task_overview"` | `"task_overview"` starts on the saved task listing; `"last_task"` resumes a local recent task when available. | + +The portable setting is exposed as `startupPage`. Its target is deliberately +not portable: `localStorage["kandev.recentTasks.v1"]` remains the source of +the newest task on the current device. Resolution only considers entries for +the active workspace. + ## API surface The existing user-settings read response includes: ```json { - "tasks_list_show_details": false + "tasks_list_show_details": false, + "startup_page": "task_overview" } ``` The existing user-settings update request accepts an optional -`tasks_list_show_details` boolean. Omitting it leaves the saved value unchanged. +`tasks_list_show_details` boolean and optional `startup_page` string. +`startup_page` accepts `"task_overview"` or `"last_task"`; omitting either +field leaves its saved value unchanged. No new endpoint is introduced. @@ -93,6 +116,10 @@ No new endpoint is introduced. - If saving **Show task details** fails, the optimistic display remains for the current page; the backend value wins after the next authoritative reload, following existing user-settings behavior. +- An absent, malformed, or unknown `startup_page` value defaults to **Task + overview**. +- When **Last visited task** has no recent task in the active workspace, or + browser storage is unavailable, bare home falls back to the task overview. - Missing repository or pull-request data omits only that metadata; it does not hide the task row or block navigation. @@ -102,6 +129,9 @@ No new endpoint is introduced. device and does not sync to other devices or users. - **Show task details** survives Kandev and browser restarts and syncs through the authenticated user's backend settings. +- The startup-page choice survives Kandev and browser restarts and syncs through + backend user settings; the exact recent task remains device-local. +- A saved recent task from another workspace never becomes the startup target. - A phone's effective Kanban fallback for a saved Pipeline preference is never persisted over the saved device preference. @@ -131,6 +161,14 @@ No new endpoint is introduced. task opens and all secondary row actions remain independently touchable. - **GIVEN** the user enables **Show task details**, **WHEN** the page or app is reloaded, **THEN** the option remains enabled from backend user settings. +- **GIVEN** **Last visited task** is selected and the active workspace has a + recent local task, **WHEN** Kandev starts or bare home opens, **THEN** that + task opens. +- **GIVEN** **Last visited task** is selected, **WHEN** the user uses Home or a + task's Back action, **THEN** the task overview opens instead of resuming a + task. +- **GIVEN** the newest local recent task belongs to another workspace, **WHEN** + bare home opens in the active workspace, **THEN** the task overview opens. ## Out of scope @@ -140,4 +178,7 @@ No new endpoint is introduced. fields. - Changing existing sort, group, archive, pagination, or preview-panel behavior. +- Syncing the exact last visited task between devices. +- Redirecting explicit task, session, workflow, Home, or Back destinations to a + recent task. - Removing the legacy `kanban_view_mode` API field in this change.