feat: add last task startup preference (#2102)
This commit is contained in:
@@ -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 <pkg>` whenever
|
||||
// npx is on PATH. MatchedPath is tagged "npx <pkg>" so the settings page
|
||||
// renders "Detected at npx <pkg>", giving users an honest hint that the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(<PageClient workspaceId="workspace-1" />);
|
||||
|
||||
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(<PageClient workspaceId="workspace-1" />);
|
||||
|
||||
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(<PageClient workspaceId="workspace-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalledWith("/tasks?workspace=workspace-1");
|
||||
});
|
||||
expect(replaceMock).not.toHaveBeenCalledWith("/t/last-task");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof getRecentTasks> | 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 (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center bg-background">
|
||||
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
|
||||
Opening last task…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<AppSidebarNavItem
|
||||
icon={IconHome}
|
||||
label="Home"
|
||||
href={inOffice ? "/office" : "/"}
|
||||
href={inOffice ? "/office" : linkToTaskOverview({ workspaceId: workspaceId ?? undefined })}
|
||||
collapsed={collapsed}
|
||||
exactMatch
|
||||
/>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<typeof useRouter>["push"];
|
||||
@@ -36,7 +37,7 @@ function buildNavigationCommands(push: PushFn): CommandItem[] {
|
||||
group: "Navigation",
|
||||
icon: <IconHome className="size-3.5" />,
|
||||
keywords: ["home", "kanban", "board"],
|
||||
action: () => push("/"),
|
||||
action: () => push(linkToTaskOverview()),
|
||||
},
|
||||
{
|
||||
id: "nav-tasks",
|
||||
|
||||
@@ -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(<HomepageCommands onCreateTask={vi.fn()} />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<CommandItem[]>(() => {
|
||||
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: <IconList className="size-3.5" />,
|
||||
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);
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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<typeof useSystemHealthIndicator>) {
|
||||
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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -108,7 +117,7 @@ function TopbarBreadcrumb({
|
||||
<>
|
||||
<BreadcrumbItem className="shrink-0 md:hidden" data-testid="topbar-phone-home">
|
||||
<BreadcrumbLink asChild>
|
||||
<BackLink href="/" label="Home" />
|
||||
<BackLink href={linkToTaskOverview()} label="Home" isHome />
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="shrink-0 md:hidden" />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<UserSettingsState, "changesPanelLayout" | "systemMetricsDisplay">,
|
||||
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 (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconHome className="h-5 w-5" />}
|
||||
title="Startup Page"
|
||||
description="Choose what opens when Kandev starts or you visit its bare home page"
|
||||
>
|
||||
<StartupPageSettingsCard value={value} isDirty={isDirty} onChange={onChange} />
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceThemeSection({
|
||||
theme,
|
||||
isDirty,
|
||||
onChange,
|
||||
}: {
|
||||
theme: Theme;
|
||||
isDirty: boolean;
|
||||
onChange: (theme: Theme) => void;
|
||||
}) {
|
||||
return (
|
||||
<SettingsSection
|
||||
icon={<IconPalette className="h-5 w-5" />}
|
||||
title="Appearance"
|
||||
description="Customize how the application looks"
|
||||
>
|
||||
<ThemeSettingsCard theme={theme} isDirty={isDirty} onChange={onChange} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralSettings() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection
|
||||
icon={<IconPalette className="h-5 w-5" />}
|
||||
title="Appearance"
|
||||
description="Customize how the application looks"
|
||||
>
|
||||
<ThemeSettingsCard
|
||||
theme={draft.theme}
|
||||
isDirty={draft.theme !== saved.theme}
|
||||
onChange={(theme) => {
|
||||
updateDraft({ theme });
|
||||
previewTheme(theme);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<AppearanceThemeSection
|
||||
theme={draft.theme}
|
||||
isDirty={draft.theme !== saved.theme}
|
||||
onChange={(theme) => {
|
||||
updateDraft({ theme });
|
||||
previewTheme(theme);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<StartupPageSettingsSection
|
||||
value={draft.startupPage}
|
||||
isDirty={draft.startupPage !== saved.startupPage}
|
||||
onChange={(startupPage) => updateDraft({ startupPage })}
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
|
||||
@@ -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 }) {
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href="/"
|
||||
href={linkToTaskOverview()}
|
||||
className="flex h-10 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm font-medium text-foreground/80 transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<SettingsCard isDirty={isDirty} data-testid="startup-page-settings-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Open Kandev to</CardTitle>
|
||||
<CardDescription>
|
||||
This applies when Kandev starts or you open its bare home page. Home, Back, and workflow
|
||||
navigation always open the task overview.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
aria-label="Startup page"
|
||||
value={value}
|
||||
onValueChange={(next) => 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 (
|
||||
<Label
|
||||
key={option.value}
|
||||
htmlFor={`startup-page-${option.value}`}
|
||||
className={`flex min-h-11 w-full min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
|
||||
selected ? "border-primary bg-primary/5" : "border-border hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={`startup-page-${option.value}`}
|
||||
value={option.value}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
className="mt-0.5 border border-muted-foreground/80 data-[state=checked]:border-primary"
|
||||
/>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span id={labelId} className="block text-sm font-medium">
|
||||
{option.label}
|
||||
</span>
|
||||
<span
|
||||
id={descriptionId}
|
||||
className="block whitespace-normal break-words text-xs text-muted-foreground"
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<header className="flex items-center justify-between px-2 py-2 bg-background">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Button variant="ghost" size="icon-sm" asChild>
|
||||
<Link href="/">
|
||||
<Link
|
||||
href={linkToTaskOverview({ workspaceId: props.workspaceId ?? undefined })}
|
||||
aria-label="Task overview"
|
||||
>
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -195,6 +195,8 @@ export const test = backendFixture.extend<
|
||||
// test renders cards with data-testid="pipeline-task-<id>" instead of
|
||||
// "task-card-<id>", 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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -49,6 +49,7 @@ function makeUnloadedSettings(): UserSettingsState {
|
||||
workspaceId: null,
|
||||
workflowId: null,
|
||||
kanbanViewMode: null,
|
||||
startupPage: "task_overview",
|
||||
repositoryIds: [],
|
||||
tasksListSort: "updated_desc",
|
||||
tasksListGroup: "state",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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/"];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Partial<UserSettings>, "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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
TaskCreateLastUsedApi,
|
||||
AppStatusBarOrderApi,
|
||||
MCPTaskAgentProfileDefault,
|
||||
StartupPage,
|
||||
UserSettings,
|
||||
UserSettingsResponse,
|
||||
UserSettingsUpdatePayload,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<BackendMessageMap["user.settings.updated"]["payload"]>),
|
||||
);
|
||||
expect(store.getState().userSettings.startupPage).toBe("last_task");
|
||||
|
||||
registerUsersHandlers(store)["user.settings.updated"]?.(
|
||||
userSettingsMessage({
|
||||
startup_page: "unexpected",
|
||||
} as unknown as Partial<BackendMessageMap["user.settings.updated"]["payload"]>),
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user