feat(ui): allow Threads as the default Home view (#3576)
This commit is contained in:
@@ -102,13 +102,19 @@ func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *tes
|
||||
|
||||
// TestMapUserSettingsStateIncludesNormalizedStartupPage verifies boot state normalizes the startup page.
|
||||
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"])
|
||||
for _, tt := range []struct{ value, want string }{
|
||||
{"future_value", usermodels.StartupPageTaskOverview},
|
||||
{"threads", "threads"},
|
||||
{usermodels.StartupPageLastTask, usermodels.StartupPageLastTask},
|
||||
} {
|
||||
t.Run(tt.value, func(t *testing.T) {
|
||||
state := mapUserSettingsState(userdto.UserSettingsResponse{
|
||||
Settings: userdto.UserSettingsDTO{StartupPage: tt.value},
|
||||
}, "workspace-1")
|
||||
if got := state["startupPage"]; got != tt.want {
|
||||
t.Fatalf("startupPage = %#v, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestUpdateUserSettingsMapsStartupPage(t *testing.T) {
|
||||
StartupPage: models.StartupPageTaskOverview,
|
||||
}}
|
||||
controller := NewController(service.NewService(repo, nil, log))
|
||||
want := models.StartupPageLastTask
|
||||
want := "threads"
|
||||
|
||||
response, err := controller.UpdateUserSettings(context.Background(), dto.UpdateUserSettingsRequest{
|
||||
StartupPage: &want,
|
||||
|
||||
@@ -394,6 +394,7 @@ func TestFromUserSettingsIncludesNormalizedStartupPage(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{name: "last task", value: models.StartupPageLastTask, want: models.StartupPageLastTask},
|
||||
{name: "threads", value: "threads", want: "threads"},
|
||||
{name: "unknown defaults to task overview", value: "future_value", want: models.StartupPageTaskOverview},
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,12 @@ func NormalizeLspStatusLocation(value string) string {
|
||||
const (
|
||||
StartupPageTaskOverview = "task_overview"
|
||||
StartupPageLastTask = "last_task"
|
||||
StartupPageThreads = "threads"
|
||||
)
|
||||
|
||||
// NormalizeStartupPage returns the canonical startup page: last_task is
|
||||
// accepted as-is, anything else is coerced to task_overview.
|
||||
// NormalizeStartupPage preserves supported choices and defaults to task_overview.
|
||||
func NormalizeStartupPage(value string) string {
|
||||
if value == StartupPageLastTask {
|
||||
if value == StartupPageLastTask || value == StartupPageThreads {
|
||||
return value
|
||||
}
|
||||
return StartupPageTaskOverview
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.5
|
||||
func TestNormalizeStartupPage(t *testing.T) {
|
||||
for _, tt := range []struct{ value, want string }{
|
||||
{"threads", "threads"},
|
||||
{"last_task", "last_task"},
|
||||
{"task_overview", "task_overview"},
|
||||
{"", "task_overview"},
|
||||
{"future_value", "task_overview"},
|
||||
} {
|
||||
t.Run(tt.value, func(t *testing.T) {
|
||||
if got := NormalizeStartupPage(tt.value); got != tt.want {
|
||||
t.Fatalf("NormalizeStartupPage(%q) = %q, want %q", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -552,11 +552,12 @@ func applyStartupPage(settings *models.UserSettings, value *string) error {
|
||||
}
|
||||
v := strings.TrimSpace(*value)
|
||||
switch v {
|
||||
case models.StartupPageTaskOverview, models.StartupPageLastTask:
|
||||
case models.StartupPageTaskOverview, models.StartupPageLastTask, models.StartupPageThreads:
|
||||
settings.StartupPage = v
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("startup_page must be %q or %q", models.StartupPageTaskOverview, models.StartupPageLastTask)
|
||||
return fmt.Errorf("startup_page must be %q, %q, or %q",
|
||||
models.StartupPageTaskOverview, models.StartupPageLastTask, models.StartupPageThreads)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -790,7 +790,7 @@ func TestApplyStartupPage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []string{models.StartupPageTaskOverview, models.StartupPageLastTask} {
|
||||
for _, value := range []string{models.StartupPageTaskOverview, models.StartupPageLastTask, "threads"} {
|
||||
t.Run("applies "+value, func(t *testing.T) {
|
||||
settings := &models.UserSettings{StartupPage: models.StartupPageTaskOverview}
|
||||
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{StartupPage: ptr(value)}); err != nil {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3
|
||||
func TestPublishThreadsStartupPage(t *testing.T) {
|
||||
log, err := logger.NewFromZap(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventBus := &recordingEventBus{}
|
||||
svc := NewService(&recordingUserRepository{}, eventBus, log)
|
||||
svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{StartupPage: "threads"})
|
||||
if len(eventBus.publishedEvents) != 1 {
|
||||
t.Fatalf("published %d settings events, want 1", len(eventBus.publishedEvents))
|
||||
}
|
||||
data, ok := eventBus.publishedEvents[0].Data.(map[string]interface{})
|
||||
if !ok || data["startup_page"] != "threads" {
|
||||
t.Fatalf("settings event = %#v, want Threads startup page", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadsStartupPagePatch(t *testing.T) {
|
||||
settings := &models.UserSettings{StartupPage: "threads"}
|
||||
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{ChangesPanelLayout: ptr("flat")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.StartupPage != "threads" {
|
||||
t.Fatalf("unrelated patch changed startup page to %q", settings.StartupPage)
|
||||
}
|
||||
if err := applyStartupPage(settings, ptr("invalid")); err == nil || settings.StartupPage != "threads" {
|
||||
t.Fatalf("invalid patch: startup page = %q, error = %v", settings.StartupPage, err)
|
||||
}
|
||||
if err := applyStartupPage(settings, ptr(" threads ")); err != nil || settings.StartupPage != "threads" {
|
||||
t.Fatalf("trimmed patch: startup page = %q, error = %v", settings.StartupPage, err)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ func TestScanUserSettingsStartupPage(t *testing.T) {
|
||||
{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"},
|
||||
{name: "threads is preserved", raw: `{"startup_page":"threads"}`, want: "threads"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
)
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3
|
||||
func TestSQLiteRepositoryStartupPagePersistence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "users.db")
|
||||
conn := openStartupPageSQLite(t, path)
|
||||
writeStartupPageSettings(t, conn)
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertStartupPageSettings(t, openStartupPageSQLite(t, path))
|
||||
}
|
||||
|
||||
func TestPostgresRepositoryStartupPagePersistence(t *testing.T) {
|
||||
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
writeStartupPageSettings(t, conn)
|
||||
assertStartupPageSettings(t, conn)
|
||||
}
|
||||
|
||||
func openStartupPageSQLite(t *testing.T, path string) *sqlx.DB {
|
||||
t.Helper()
|
||||
conn, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
conn.SetMaxOpenConns(1)
|
||||
return conn
|
||||
}
|
||||
|
||||
func writeStartupPageSettings(t *testing.T, conn *sqlx.DB) {
|
||||
t.Helper()
|
||||
repo, err := newSQLiteRepositoryWithDB(conn, conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateUser(ctx, &models.User{
|
||||
ID: "other-user", Email: "other@example.test", Role: models.RoleMember, Status: models.StatusActive,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.StartupPage = "threads"
|
||||
upsertUserSettingsForTest(t, repo, ctx, settings)
|
||||
saved, err := repo.GetUserSettings(ctx, DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.StartupPage != "threads" {
|
||||
t.Fatalf("saved startup page = %q, want threads", saved.StartupPage)
|
||||
}
|
||||
saved.TasksListShowDetails = true
|
||||
upsertUserSettingsForTest(t, repo, ctx, saved)
|
||||
}
|
||||
|
||||
func assertStartupPageSettings(t *testing.T, conn *sqlx.DB) {
|
||||
t.Helper()
|
||||
repo, err := newSQLiteRepositoryWithDB(conn, conn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := repo.GetUserSettings(context.Background(), DefaultUserID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.StartupPage != "threads" || !settings.TasksListShowDetails || settings.Revision != 2 {
|
||||
t.Fatalf("reopened settings: startup=%q details=%v revision=%d", settings.StartupPage,
|
||||
settings.TasksListShowDetails, settings.Revision)
|
||||
}
|
||||
other, err := repo.GetUserSettings(context.Background(), "other-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.StartupPage != models.StartupPageTaskOverview || other.Revision != 0 {
|
||||
t.Fatalf("other user's settings changed: startup=%q revision=%d", other.StartupPage, other.Revision)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,58 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("PageClient", () => {
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4, 003.5, 003.7
|
||||
it.each(["kanban", "pipeline", "list", "threads"])(
|
||||
"prefers fixed Threads over remembered %s",
|
||||
async (view) => {
|
||||
startupPageMock.value = "threads";
|
||||
preferredViewMock.value = view;
|
||||
render(<PageClient workspaceId="workspace-1" />);
|
||||
await waitFor(() =>
|
||||
expect(replaceMock).toHaveBeenCalledWith("/threads?workspace=workspace-1"),
|
||||
);
|
||||
expect(
|
||||
replaceMock.mock.calls.every(([href]) => href === "/threads?workspace=workspace-1"),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets explicit overview use the remembered List despite fixed Threads", async () => {
|
||||
startupPageMock.value = "threads";
|
||||
searchMock.value = "home=overview";
|
||||
render(<PageClient workspaceId="workspace-1" />);
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/tasks?workspace=workspace-1"));
|
||||
});
|
||||
|
||||
it("keeps explicit task and session props with fixed Threads", () => {
|
||||
startupPageMock.value = "threads";
|
||||
searchMock.value = "";
|
||||
const { rerender } = render(<PageClient workspaceId="workspace-1" initialTaskId="task-1" />);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
searchMock.value = "";
|
||||
rerender(<PageClient workspaceId="workspace-1" initialSessionId="session-1" />);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["workflowId=wf-1", "taskId=task-1", "sessionId=session-1"])(
|
||||
"does not restore a listing over query %s",
|
||||
(query) => {
|
||||
startupPageMock.value = "threads";
|
||||
searchMock.value = query;
|
||||
render(<PageClient workspaceId="workspace-1" />);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps onboarding available without a resolved workspace", () => {
|
||||
startupPageMock.value = "threads";
|
||||
preferredViewMock.value = "kanban";
|
||||
render(<PageClient />);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageClient existing startup choices", () => {
|
||||
it("restores List in the resolved workspace", async () => {
|
||||
render(<PageClient workspaceId="workspace-1" />);
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
|
||||
import { STORAGE_KEYS } from "@/lib/settings/constants";
|
||||
import { useRouter, useSearchParams } from "@/lib/routing/client-router";
|
||||
import { useTaskListingView } from "@/hooks/use-task-listing-view";
|
||||
import { linkToTask, linkToTasks, linkToThreads } from "@/lib/links";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { getRecentTasks } from "@/lib/recent-tasks";
|
||||
import { isExplicitHomeDestination, resolveStartupTaskId } from "@/lib/startup-page";
|
||||
import { resolveHomeTaskListingRedirect } from "@/lib/task-listing/view-preference";
|
||||
import {
|
||||
isExplicitHomeDestination,
|
||||
resolveStartupTaskId,
|
||||
resolveStartupListingRedirect,
|
||||
} from "@/lib/startup-page";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PageClientProps = {
|
||||
@@ -41,7 +44,16 @@ export function PageClient({ workspaceId, initialTaskId, initialSessionId }: Pag
|
||||
});
|
||||
const isResolvingStartupTask =
|
||||
startupPage === "last_task" && !hasExplicitDestination && recentTasks === null;
|
||||
const hasWorkflowFilter = Boolean(searchParams.get("workflowId"));
|
||||
const redirectHref = startupTaskId
|
||||
? linkToTask(startupTaskId)
|
||||
: resolveStartupListingRedirect({
|
||||
startupPage,
|
||||
preferredView,
|
||||
workspaceId,
|
||||
searchParams,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
});
|
||||
const [showOnboarding, setShowOnboarding] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const completed = getLocalStorage(STORAGE_KEYS.ONBOARDING_COMPLETED, false);
|
||||
@@ -60,29 +72,8 @@ export function PageClient({ workspaceId, initialTaskId, initialSessionId }: Pag
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResolvingStartupTask) return;
|
||||
if (startupTaskId) {
|
||||
router.replace(linkToTask(startupTaskId));
|
||||
return;
|
||||
}
|
||||
if (hasWorkflowFilter) return;
|
||||
const routedView = resolveHomeTaskListingRedirect(
|
||||
preferredView,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
);
|
||||
if (routedView === "list") router.replace(linkToTasks(workspaceId));
|
||||
if (routedView === "threads") router.replace(linkToThreads(workspaceId));
|
||||
}, [
|
||||
hasWorkflowFilter,
|
||||
initialSessionId,
|
||||
initialTaskId,
|
||||
isResolvingStartupTask,
|
||||
preferredView,
|
||||
router,
|
||||
startupTaskId,
|
||||
workspaceId,
|
||||
]);
|
||||
if (!isResolvingStartupTask && redirectHref) router.replace(redirectHref);
|
||||
}, [isResolvingStartupTask, redirectHref, router]);
|
||||
|
||||
if (isResolvingStartupTask || startupTaskId) {
|
||||
return (
|
||||
|
||||
@@ -37,7 +37,7 @@ const state = {
|
||||
user: null as { display_name: string; email: string } | null,
|
||||
},
|
||||
connection: { issueSeverity: "none" as "none" | "unstable" | "lost" },
|
||||
userSettings: { appStatusBarEnabled: true },
|
||||
userSettings: { appStatusBarEnabled: true, startupPage: "task_overview" },
|
||||
};
|
||||
|
||||
const DEFAULT_PATHNAME = "/tasks/session-1";
|
||||
|
||||
@@ -333,6 +333,7 @@ function useSettingsGearToggle(
|
||||
) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const startupPage = useAppStore((s) => s.userSettings.startupPage);
|
||||
|
||||
return () => {
|
||||
const onSettingsRoute = isSettingsRoute(pathname);
|
||||
@@ -341,7 +342,9 @@ function useSettingsGearToggle(
|
||||
return;
|
||||
}
|
||||
if (settingsMode && onSettingsRoute) {
|
||||
router.push(workspaceHomeHref(activeWorkspace), { onNavigated: onToggleSettingsMode });
|
||||
router.push(workspaceHomeHref(activeWorkspace, startupPage), {
|
||||
onNavigated: onToggleSettingsMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
onToggleSettingsMode();
|
||||
|
||||
@@ -14,6 +14,7 @@ const state = {
|
||||
],
|
||||
},
|
||||
features: { office: true },
|
||||
userSettings: { startupPage: "task_overview" },
|
||||
appSidebar: { workspacePickerOpen: false },
|
||||
setWorkspacePickerOpen,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer";
|
||||
export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const workspaces = useAppStore((s) => s.workspaces);
|
||||
const startupPage = useAppStore((s) => s.userSettings.startupPage);
|
||||
// The global WORKSPACE_PICKER shortcut opens this instance (and only this
|
||||
// one) through the store; the mobile sheet keeps its own local open state.
|
||||
const pickerOpen = useAppStore((s) => s.appSidebar.workspacePickerOpen);
|
||||
@@ -30,7 +31,7 @@ export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHead
|
||||
(workspace) => workspace.id === workspaces.activeId,
|
||||
);
|
||||
const homeDisabled = mode === "unknown";
|
||||
const homeHref = homeDisabled ? "#" : workspaceHomeHref(activeWorkspace);
|
||||
const homeHref = homeDisabled ? "#" : workspaceHomeHref(activeWorkspace, startupPage);
|
||||
|
||||
if (collapsed) {
|
||||
// Minimal rail: brand home + expand. The workspace switcher lives only in
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openQuickChat: vi.fn(),
|
||||
@@ -8,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
const state = {
|
||||
workspaces: { activeId: "ws-1" as string | null },
|
||||
userSettings: { ...defaultState.userSettings },
|
||||
office: { inboxCountByWorkspaceId: {} as Record<string, number> },
|
||||
quickChat: {
|
||||
isOpen: false,
|
||||
@@ -64,6 +66,7 @@ function renderNav(collapsed: boolean) {
|
||||
describe("AppSidebarPrimaryNav", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = "ws-1";
|
||||
state.userSettings = { ...defaultState.userSettings };
|
||||
state.office.inboxCountByWorkspaceId = {};
|
||||
state.quickChat.isOpen = false;
|
||||
state.quickChat.sessions = [];
|
||||
@@ -138,11 +141,17 @@ describe("AppSidebarPrimaryNav", () => {
|
||||
expect(screen.queryByRole("button", { name: QUICK_CHAT_LABEL })).toBeNull();
|
||||
});
|
||||
|
||||
it("links Home to an explicit overview while keeping it active at the root route", () => {
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4
|
||||
it.each([
|
||||
["task_overview", "/?home=overview&workspaceId=ws-1"],
|
||||
["threads", "/threads?workspace=ws-1"],
|
||||
] as const)("links Home for %s and marks its route active", (startupPage, href) => {
|
||||
state.userSettings.startupPage = startupPage;
|
||||
pathname = href.split("?")[0];
|
||||
renderNav(false);
|
||||
|
||||
const home = screen.getByRole("link", { name: "Home" });
|
||||
expect(home.getAttribute("href")).toBe("/?home=overview&workspaceId=ws-1");
|
||||
expect(home.getAttribute("href")).toBe(href);
|
||||
expect(home.className).toContain("before:bg-primary");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@ type AppSidebarPrimaryNavProps = {
|
||||
export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const startupPage = useAppStore((s) => s.userSettings.startupPage);
|
||||
const inboxCount = useAppStore(selectOfficeInboxCount);
|
||||
const mode = useOfficeModeState();
|
||||
const inOffice = mode === "office";
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId);
|
||||
const homeHref = mode === "unknown" ? undefined : homeDestinationHref({ workspaceId, inOffice });
|
||||
const homeHref =
|
||||
mode === "unknown" ? undefined : homeDestinationHref({ workspaceId, inOffice, startupPage });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { StartupPage } from "@/lib/types/http-user-settings";
|
||||
|
||||
// The sidebar write path scopes names with the API-origin port; pin it so the
|
||||
// captured write assertions are deterministic.
|
||||
@@ -44,6 +45,7 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({
|
||||
|
||||
const storeState = {
|
||||
features: { office: false },
|
||||
userSettings: { startupPage: "task_overview" as StartupPage },
|
||||
workspaces: {
|
||||
items: [
|
||||
{ id: "w1", name: "Default Workspace", office_workflow_id: "" },
|
||||
@@ -66,6 +68,7 @@ let cookieDescriptor: PropertyDescriptor | undefined;
|
||||
function resetWorkspaceSelectTest() {
|
||||
navigationMock.push = vi.fn();
|
||||
storeState.features.office = false;
|
||||
storeState.userSettings.startupPage = "task_overview";
|
||||
storeState.workspaces.activeId = "w1";
|
||||
storeState.setActiveWorkspace = vi.fn();
|
||||
storeState.resetKanbanWorkspaceContext = vi.fn();
|
||||
@@ -97,6 +100,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => {
|
||||
beforeEach(() => {
|
||||
navigationMock.push = vi.fn();
|
||||
storeState.features.office = false;
|
||||
storeState.userSettings.startupPage = "task_overview";
|
||||
storeState.workspaces.activeId = "w1";
|
||||
storeState.setActiveWorkspace = vi.fn();
|
||||
});
|
||||
@@ -184,6 +188,26 @@ describe("AppSidebarWorkspacePicker — workspace select", () => {
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/?home=overview&workspaceId=w3");
|
||||
});
|
||||
|
||||
// Contract coverage for the saved Home default on workspace selection.
|
||||
it("uses the Threads default in the selected workspace", () => {
|
||||
storeState.userSettings.startupPage = "threads";
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(ALTERNATE_KANBAN_WORKSPACE_ITEM));
|
||||
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/threads?workspace=w3");
|
||||
});
|
||||
|
||||
it("keeps Office priority over the Threads default", () => {
|
||||
storeState.features.office = true;
|
||||
storeState.userSettings.startupPage = "threads";
|
||||
render(<AppSidebarWorkspacePicker />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM));
|
||||
|
||||
expect(navigationMock.push).toHaveBeenCalledWith("/office?workspaceId=w2");
|
||||
});
|
||||
|
||||
it("calls onActionComplete when selecting a different workspace", () => {
|
||||
const onActionComplete = vi.fn();
|
||||
storeState.features.office = false;
|
||||
|
||||
@@ -108,6 +108,7 @@ export function AppSidebarWorkspacePicker({
|
||||
const router = useRouter();
|
||||
const officeEnabled = useFeature("office");
|
||||
const workspaces = useAppStore((s) => s.workspaces);
|
||||
const startupPage = useAppStore((s) => s.userSettings.startupPage);
|
||||
const selectWorkspace = useSelectWorkspace();
|
||||
const resetKanbanWorkspaceContext = useAppStore((s) => s.resetKanbanWorkspaceContext);
|
||||
const { open, setOpen } = useMenuOpenState(controlledOpen, onOpenChange);
|
||||
@@ -123,7 +124,7 @@ export function AppSidebarWorkspacePicker({
|
||||
if (id === activeId) {
|
||||
if (officeEnabled && workspaceType(workspace) === "kanban") {
|
||||
selectWorkspace(workspace);
|
||||
router.push(workspaceHomeHref(workspace));
|
||||
router.push(workspaceHomeHref(workspace, startupPage));
|
||||
}
|
||||
setOpen(false);
|
||||
onActionComplete?.();
|
||||
@@ -132,7 +133,7 @@ export function AppSidebarWorkspacePicker({
|
||||
resetKanbanWorkspaceContext();
|
||||
selectWorkspace(workspace);
|
||||
if (workspaceType(workspace) === "kanban") {
|
||||
router.push(workspaceHomeHref(workspace));
|
||||
router.push(workspaceHomeHref(workspace, startupPage));
|
||||
} else if (officeEnabled) {
|
||||
router.push(`/office?workspaceId=${id}`);
|
||||
}
|
||||
@@ -141,6 +142,7 @@ export function AppSidebarWorkspacePicker({
|
||||
},
|
||||
[
|
||||
activeId,
|
||||
startupPage,
|
||||
router,
|
||||
selectWorkspace,
|
||||
resetKanbanWorkspaceContext,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import type { NavItem } from "@/lib/plugins/types";
|
||||
import type { GitHubStatus } from "@/lib/types/github";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
|
||||
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
|
||||
@@ -68,6 +69,7 @@ vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (
|
||||
selector: (state: {
|
||||
workspaces: { activeId: string | null; items: Array<{ id: string }> };
|
||||
userSettings: typeof defaultState.userSettings;
|
||||
// The navigation manifest resolves hrefs against the active mode, so
|
||||
// `useInOffice` (and through it `useFeature`) now runs in this tree.
|
||||
features: Record<string, boolean>;
|
||||
@@ -75,6 +77,7 @@ vi.mock("@/components/state-provider", () => ({
|
||||
) =>
|
||||
selector({
|
||||
workspaces: { activeId: activeWorkspaceRef.id, items: activeWorkspaceRef.items },
|
||||
userSettings: { ...defaultState.userSettings },
|
||||
features: { office: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { JSX } from "react";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { AppNavSheet } from "./app-nav-sheet";
|
||||
import { AppNavSections, useAppNavDialogs } from "./app-nav-sections";
|
||||
|
||||
@@ -18,8 +19,13 @@ const ARIA_LABEL = "aria-label";
|
||||
const state = {
|
||||
features: { canvases: false },
|
||||
workspaces: { activeId: "ws-1" as string | null },
|
||||
userSettings: { ...defaultState.userSettings },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.userSettings = { ...defaultState.userSettings };
|
||||
});
|
||||
|
||||
let healthHasIssues = false;
|
||||
let statusSeverity: "none" | "unstable" | "lost" = "none";
|
||||
let workspaceActionsRegistrations: Array<{
|
||||
@@ -156,14 +162,17 @@ describe("AppNavSheet", () => {
|
||||
expect(pageNavIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("routes the Home row through the manifest href", () => {
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4
|
||||
it.each([
|
||||
["task_overview", "/?home=overview&workspaceId=ws-1"],
|
||||
["threads", "/threads?workspace=ws-1"],
|
||||
] as const)("routes the Home row through the manifest href for %s", (startupPage, href) => {
|
||||
state.userSettings.startupPage = startupPage;
|
||||
render(<AppNavSheet />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("app-nav-trigger"));
|
||||
|
||||
expect(screen.getByRole("link", { name: "Home" }).getAttribute("href")).toBe(
|
||||
"/?home=overview&workspaceId=ws-1",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Home" }).getAttribute("href")).toBe(href);
|
||||
});
|
||||
|
||||
it("exposes workspace actions through the shared phone navigation sheet", () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import {
|
||||
appearanceRevision,
|
||||
buildAppearanceUserSettingsPatch,
|
||||
createAppearanceSavedState,
|
||||
rebaseAppearanceDraft,
|
||||
} from "./appearance-settings-state";
|
||||
|
||||
// Existing contributor contract, extended to the new enum value without new save logic.
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.2
|
||||
describe("Threads appearance draft", () => {
|
||||
const saved = createAppearanceSavedState("dark", "flat", true, defaultState.userSettings);
|
||||
it("only patches the explicit choice when selected", () => {
|
||||
const draft = { ...saved, startupPage: "threads" as const };
|
||||
expect(buildAppearanceUserSettingsPatch(draft, saved)).toEqual({ startup_page: "threads" });
|
||||
expect(appearanceRevision(draft)).not.toBe(appearanceRevision(saved));
|
||||
expect(saved.startupPage).toBe("task_overview");
|
||||
});
|
||||
it("preserves saved Threads through unrelated changes and an unchanged save", () => {
|
||||
const threads = { ...saved, startupPage: "threads" as const };
|
||||
expect(buildAppearanceUserSettingsPatch(threads, threads)).toEqual({});
|
||||
expect(
|
||||
buildAppearanceUserSettingsPatch({ ...threads, appStatusBarEnabled: true }, threads),
|
||||
).toEqual({ app_status_bar_enabled: true });
|
||||
});
|
||||
it("rebases clean fields while retaining a pending Threads choice", () => {
|
||||
const next = { ...saved, appStatusBarEnabled: true };
|
||||
expect(rebaseAppearanceDraft({ ...saved, startupPage: "threads" }, saved, next)).toEqual({
|
||||
...next,
|
||||
startupPage: "threads",
|
||||
});
|
||||
expect(rebaseAppearanceDraft(saved, saved, { ...next, startupPage: "threads" })).toEqual({
|
||||
...next,
|
||||
startupPage: "threads",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,11 @@ const OPTIONS: Array<{ value: StartupPage; labelKey: string; descriptionKey: str
|
||||
labelKey: "settings:lastVisitedTask",
|
||||
descriptionKey: "settings:resumeTheMostRecentlyOpenedTask",
|
||||
},
|
||||
{
|
||||
value: "threads",
|
||||
labelKey: "kanban:threads",
|
||||
descriptionKey: "settings:openThreadsOnStartupAndHome",
|
||||
},
|
||||
];
|
||||
|
||||
export function StartupPageSettingsCard({
|
||||
|
||||
@@ -47,3 +47,41 @@ describe("ApiClient.createAgentProfile", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiClient user settings", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// Contract coverage for the existing settings restore flow.
|
||||
it("round-trips the startup choice and workspace scope", async () => {
|
||||
const baseline = {
|
||||
startup_page: "threads",
|
||||
workspace_id: "workspace-1",
|
||||
workflow_filter_id: "workflow-1",
|
||||
};
|
||||
let saved: unknown;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/app-state?path=%2Fsettings%2Fagents")) {
|
||||
return Response.json({ interimSettingsInterlockToken: "test-token" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/user/settings")) {
|
||||
if (init?.method === "PATCH") saved = JSON.parse(String(init.body));
|
||||
return Response.json({ settings: baseline });
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
}),
|
||||
);
|
||||
const client = new ApiClient("http://backend.test");
|
||||
const { settings } = await client.getUserSettings();
|
||||
await client.saveUserSettings({
|
||||
startup_page: settings.startup_page,
|
||||
workspace_id: settings.workspace_id,
|
||||
workflow_filter_id: settings.workflow_filter_id,
|
||||
});
|
||||
expect(saved).toEqual(baseline);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1174,10 +1174,12 @@ export class ApiClient {
|
||||
|
||||
async getUserSettings(): Promise<{
|
||||
settings: {
|
||||
workspace_id?: string;
|
||||
workflow_filter_id?: string;
|
||||
terminal_link_behavior?: string;
|
||||
terminal_font_family?: string;
|
||||
terminal_font_size?: number;
|
||||
startup_page?: "task_overview" | "last_task";
|
||||
startup_page?: "task_overview" | "last_task" | "threads";
|
||||
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
|
||||
tasks_list_show_details?: boolean;
|
||||
show_transcript_auto_scroll_control?: boolean;
|
||||
@@ -1208,7 +1210,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";
|
||||
startup_page?: "task_overview" | "last_task" | "threads";
|
||||
keyboard_shortcuts?: Record<string, unknown>;
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
|
||||
// Regression coverage for the PageShell migration: Office used to render a
|
||||
// title-only topbar with no menu and no home link, so on a phone the entire
|
||||
// Office section was unreachable and un-exitable (browser back excepted).
|
||||
test.describe("Office mobile navigation", () => {
|
||||
let baseline: Awaited<ReturnType<ApiClient["getUserSettings"]>>["settings"];
|
||||
let createdWorkspace: { id: string; name: string } | undefined;
|
||||
test.beforeEach(async ({ testPage, apiClient }) => {
|
||||
void testPage;
|
||||
createdWorkspace = undefined;
|
||||
baseline = (await apiClient.getUserSettings()).settings;
|
||||
await apiClient.saveUserSettings({ startup_page: "threads" });
|
||||
});
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
if (baseline)
|
||||
await apiClient.saveUserSettings({
|
||||
startup_page: baseline.startup_page ?? "task_overview",
|
||||
workspace_id: baseline.workspace_id,
|
||||
});
|
||||
if (createdWorkspace)
|
||||
await apiClient.deleteWorkspace(createdWorkspace.id, createdWorkspace.name);
|
||||
});
|
||||
test("offers office sections and a home row in the shared nav sheet", async ({
|
||||
testPage,
|
||||
officeSeed: _,
|
||||
@@ -13,7 +31,7 @@ test.describe("Office mobile navigation", () => {
|
||||
|
||||
const trigger = testPage.getByTestId("app-nav-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await trigger.tap();
|
||||
|
||||
const sheet = testPage.getByTestId("app-nav-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
@@ -26,9 +44,14 @@ test.describe("Office mobile navigation", () => {
|
||||
await expect(sheet.getByRole("link", { name: "Settings" })).toBeVisible();
|
||||
|
||||
// Home from inside Office lands on the office dashboard, not kanban.
|
||||
await sheet.getByRole("link", { name: "Home", exact: true }).click();
|
||||
await sheet.getByRole("link", { name: "Home", exact: true }).tap();
|
||||
await expect(sheet).not.toBeVisible();
|
||||
await expect(testPage).toHaveURL(/\/office(?:\?.*)?$/);
|
||||
|
||||
await testPage.goto("/settings/preferences/appearance");
|
||||
await expect(testPage.getByRole("radio", { name: "Threads", exact: true })).toBeChecked();
|
||||
await testPage.getByRole("link", { name: "Home", exact: true }).tap();
|
||||
await expect(testPage).toHaveURL(/\/office(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test("navigates between office sections from the sheet", async ({ testPage, officeSeed: _ }) => {
|
||||
@@ -45,6 +68,7 @@ test.describe("Office mobile navigation", () => {
|
||||
|
||||
test("switches workspaces from the Office mobile menu", async ({ testPage, apiClient }) => {
|
||||
const kanbanWorkspace = await apiClient.createWorkspace("Mobile Office Kanban Workspace");
|
||||
createdWorkspace = kanbanWorkspace;
|
||||
|
||||
await testPage.setViewportSize({ width: 390, height: 844 });
|
||||
await testPage.goto("/office/tasks");
|
||||
@@ -59,10 +83,40 @@ test.describe("Office mobile navigation", () => {
|
||||
await expect(sheet).not.toBeVisible();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname === "/" &&
|
||||
url.searchParams.get("home") === "overview" &&
|
||||
url.searchParams.get("workspaceId") === kanbanWorkspace.id,
|
||||
url.pathname === "/threads" && url.searchParams.get("workspace") === kanbanWorkspace.id,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.6
|
||||
test("keeps phone Home in the workspace's Threads when Office is disabled", async ({
|
||||
testPage,
|
||||
backend,
|
||||
officeSeed,
|
||||
}, testInfo) => {
|
||||
try {
|
||||
await backend.restart({ KANDEV_FEATURES_OFFICE: "false" });
|
||||
await testPage.goto(`/?home=overview&workspaceId=${officeSeed.workspaceId}`);
|
||||
await testPage.getByTestId("mobile-topbar-menu").tap();
|
||||
const home = testPage
|
||||
.getByRole("dialog", { name: "Menu" })
|
||||
.getByRole("link", { name: "Home", exact: true });
|
||||
await expect(home).toHaveAttribute("href", `/threads?workspace=${officeSeed.workspaceId}`);
|
||||
await home.tap();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname === "/threads" &&
|
||||
url.searchParams.get("workspace") === officeSeed.workspaceId,
|
||||
);
|
||||
const threads = testPage
|
||||
.getByTestId("threads-board")
|
||||
.or(testPage.getByTestId("threads-empty-state"));
|
||||
await expect(threads).toBeVisible();
|
||||
await testPage.reload();
|
||||
await expect(threads).toBeVisible();
|
||||
await testPage.screenshot({ path: testInfo.outputPath("office-disabled-threads-phone.png") });
|
||||
} finally {
|
||||
await backend.restart();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from "../../fixtures/office-fixture";
|
||||
import { officeTopbarTitle } from "../../helpers/office-topbar";
|
||||
import { AppSidebarPage } from "../../pages/app-sidebar-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
|
||||
test.describe("Sidebar navigation", () => {
|
||||
test("sidebar shows CEO agent link", async ({ testPage, officeSeed: _ }) => {
|
||||
@@ -72,10 +73,19 @@ test.describe("Sidebar navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The sidebar "Home" item is office-aware: while on any /office/* route it
|
||||
// targets the office dashboard (/office), and on a regular Kanban route it
|
||||
// targets the board (/). Active-highlight stays exact-match in both modes.
|
||||
// Home follows workspace mode, including when Threads is the saved default.
|
||||
test.describe("Sidebar Home destination", () => {
|
||||
let baseline: Awaited<ReturnType<ApiClient["getUserSettings"]>>["settings"];
|
||||
test.beforeEach(async ({ testPage, apiClient }) => {
|
||||
void testPage;
|
||||
baseline = (await apiClient.getUserSettings()).settings;
|
||||
await apiClient.saveUserSettings({ startup_page: "threads" });
|
||||
});
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
if (baseline)
|
||||
await apiClient.saveUserSettings({ startup_page: baseline.startup_page ?? "task_overview" });
|
||||
});
|
||||
|
||||
test("Home goes to the office dashboard from an office route", async ({
|
||||
testPage,
|
||||
officeSeed: _,
|
||||
@@ -113,6 +123,12 @@ test.describe("Sidebar Home destination", () => {
|
||||
await home.click();
|
||||
await expect(testPage).toHaveURL(/\/office(\?|$)/);
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await testPage.goto("/settings/preferences/appearance");
|
||||
await expect(testPage.getByRole("radio", { name: "Threads", exact: true })).toBeChecked();
|
||||
await testPage.getByRole("link", { name: "Kandev home", exact: true }).click();
|
||||
await expect(testPage).toHaveURL(/\/office(\?|$)/);
|
||||
await expect(testPage.getByText("Agents Enabled")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the kanban board redirects to Office when an office workspace is active", async ({
|
||||
|
||||
@@ -1,8 +1,147 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import {
|
||||
expectEmptyList,
|
||||
expectThreadsHome,
|
||||
saveStartupChoice,
|
||||
VIEW_STORAGE_KEY,
|
||||
} from "./startup-page-helpers";
|
||||
|
||||
const APPEARANCE_PATH = "/settings/preferences/appearance";
|
||||
|
||||
test.describe("Mobile Threads Home default", () => {
|
||||
let baseline: Awaited<ReturnType<ApiClient["getUserSettings"]>>["settings"];
|
||||
test.beforeEach(async ({ testPage, apiClient }) => {
|
||||
void testPage;
|
||||
baseline = (await apiClient.getUserSettings()).settings;
|
||||
});
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
if (baseline)
|
||||
await apiClient.saveUserSettings({ startup_page: baseline.startup_page ?? "task_overview" });
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.1, 003.4, 003.6, 003.8
|
||||
test("saves Threads from phone settings and returns Home after using List", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileMenuButton.tap();
|
||||
await testPage
|
||||
.getByRole("dialog", { name: "Menu" })
|
||||
.getByRole("link", { name: "Settings", exact: true })
|
||||
.tap();
|
||||
await testPage.getByRole("link", { name: "Appearance", exact: true }).tap();
|
||||
const row = testPage.locator('label[for="startup-page-threads"]');
|
||||
await expect(row).toBeVisible();
|
||||
expect((await row.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await row.tap();
|
||||
const radio = testPage.getByRole("radio", { name: "Threads", exact: true });
|
||||
await expect(radio).toBeChecked();
|
||||
const saveButton = testPage
|
||||
.getByTestId("settings-floating-save")
|
||||
.getByRole("button", { name: "Save changes" });
|
||||
await expect(saveButton).toBeVisible();
|
||||
const box = await saveButton.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(testPage.viewportSize()!.height);
|
||||
// A transient update toast may cover the action before auto-dismissal.
|
||||
await expect
|
||||
.poll(() =>
|
||||
saveButton.evaluate((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return button.contains(
|
||||
document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await testPage.screenshot({ path: testInfo.outputPath("threads-home-settings-phone.png") });
|
||||
await saveStartupChoice(testPage, true);
|
||||
await testPage.reload();
|
||||
await expect(radio).toBeChecked();
|
||||
await testPage.getByRole("link", { name: "Home", exact: true }).tap();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
await mobile.mobileMenuButton.tap();
|
||||
await testPage
|
||||
.getByRole("dialog", { name: "Menu" })
|
||||
.getByRole("radio", { name: "List", exact: true })
|
||||
.tap();
|
||||
await expectEmptyList(testPage);
|
||||
await testPage.reload();
|
||||
await expectEmptyList(testPage);
|
||||
await mobile.mobileMenuButton.tap();
|
||||
await testPage
|
||||
.getByRole("dialog", { name: "Menu" })
|
||||
.getByRole("link", { name: "Home", exact: true })
|
||||
.tap();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("threads");
|
||||
await testPage.evaluate((key) => localStorage.removeItem(key), VIEW_STORAGE_KEY);
|
||||
await testPage.goto("/");
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
await testPage.screenshot({ path: testInfo.outputPath("threads-home-deck-phone.png") });
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5, 003.7, 003.8
|
||||
test("keeps explicit phone destinations with Threads selected", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Phone explicit Threads",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("Expected a started session");
|
||||
await waitForSessionDone(apiClient, task.id, task.session_id, "phone target finishes");
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
await testPage.locator('label[for="startup-page-threads"]').tap();
|
||||
await saveStartupChoice(testPage, true);
|
||||
await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`);
|
||||
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
|
||||
await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`);
|
||||
await expect(testPage.getByTestId("session-chat")).toBeVisible();
|
||||
await testPage.getByRole("link", { name: "Task overview", exact: true }).tap();
|
||||
await expect(testPage).toHaveURL((url) => url.pathname === "/tasks");
|
||||
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
|
||||
await testPage.goto(
|
||||
`/threads?workspace=${seedData.workspaceId}&taskId=${task.id}&sessionId=${task.session_id}`,
|
||||
);
|
||||
const column = testPage.getByTestId(`thread-column-${task.id}`);
|
||||
await expect(column).toBeVisible();
|
||||
const width = (await column.boundingBox())!.width;
|
||||
expect(width).toBeGreaterThan(testPage.viewportSize()!.width * 0.7);
|
||||
expect(width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
|
||||
await testPage.reload();
|
||||
await expect(column).toBeVisible();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) =>
|
||||
url.searchParams.get("taskId") === task.id &&
|
||||
url.searchParams.get("sessionId") === task.session_id,
|
||||
);
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
await testPage.screenshot({ path: testInfo.outputPath("threads-home-focused-phone.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile startup page", () => {
|
||||
test("uses a touch-friendly saved preference, resumes the task, and returns to overview", async ({
|
||||
testPage,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
|
||||
export const VIEW_STORAGE_KEY = "kandev.taskListing.view.v1";
|
||||
|
||||
export async function saveStartupChoice(page: Page, touch = false) {
|
||||
const floatingSave = page.getByTestId("settings-floating-save");
|
||||
const button = floatingSave.getByRole("button", { name: /^(Save changes|Retry save)$/ });
|
||||
const saved = waitForHttp(page, "PATCH", /\/api\/v1\/user\/settings$/, {
|
||||
predicate: (response) => response.request().postDataJSON()?.startup_page === "threads",
|
||||
});
|
||||
if (touch) await button.tap();
|
||||
else await button.click();
|
||||
expect((await saved).status()).toBe(200);
|
||||
await expect(floatingSave).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectThreadsHome(page: Page, workspaceId: string) {
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname === "/threads" && url.searchParams.get("workspace") === workspaceId,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("threads-board").or(page.getByTestId("threads-empty-state")),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectEmptyList(page: Page) {
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/tasks");
|
||||
await expect(page.getByText("No tasks found.", { exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -1,7 +1,246 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { waitForSessionDone } from "../../helpers/session";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import {
|
||||
expectEmptyList,
|
||||
expectThreadsHome,
|
||||
saveStartupChoice,
|
||||
VIEW_STORAGE_KEY,
|
||||
} from "./startup-page-helpers";
|
||||
|
||||
const APPEARANCE_PATH = "/settings/preferences/appearance";
|
||||
|
||||
test.describe("Threads Home default", () => {
|
||||
let baseline: Awaited<ReturnType<ApiClient["getUserSettings"]>>["settings"];
|
||||
let createdWorkspace: { id: string; name: string } | undefined;
|
||||
test.beforeEach(async ({ testPage, apiClient }) => {
|
||||
void testPage;
|
||||
baseline = (await apiClient.getUserSettings()).settings;
|
||||
createdWorkspace = undefined;
|
||||
});
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
if (baseline)
|
||||
await apiClient.saveUserSettings({
|
||||
startup_page: baseline.startup_page ?? "task_overview",
|
||||
workspace_id: baseline.workspace_id,
|
||||
workflow_filter_id: baseline.workflow_filter_id,
|
||||
});
|
||||
if (createdWorkspace)
|
||||
await apiClient.deleteWorkspace(createdWorkspace.id, createdWorkspace.name);
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.1, 003.3, 003.4, 003.5, 003.7
|
||||
test("saves Threads as the Home default independently of the last listing", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
browser,
|
||||
backend,
|
||||
}) => {
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
const radio = testPage.getByRole("radio", { name: "Threads", exact: true });
|
||||
await expect(radio).toBeVisible();
|
||||
await testPage.evaluate(
|
||||
(key) => localStorage.setItem(key, JSON.stringify("list")),
|
||||
VIEW_STORAGE_KEY,
|
||||
);
|
||||
await radio.click();
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("task_overview");
|
||||
await saveStartupChoice(testPage);
|
||||
expect(await testPage.evaluate((key) => localStorage.getItem(key), VIEW_STORAGE_KEY)).toBe(
|
||||
'"list"',
|
||||
);
|
||||
await testPage.reload();
|
||||
await expect(radio).toBeChecked();
|
||||
await testPage.getByRole("link", { name: "Kandev home", exact: true }).click();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
|
||||
await testPage.getByTestId("view-toggle-list").click();
|
||||
await expectEmptyList(testPage);
|
||||
await testPage.reload();
|
||||
await expectEmptyList(testPage);
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("threads");
|
||||
await testPage.getByRole("link", { name: "Home", exact: true }).click();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
await testPage.evaluate((key) => localStorage.removeItem(key), VIEW_STORAGE_KEY);
|
||||
await testPage.goto("/");
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
|
||||
const fresh = await browser.newContext({ baseURL: backend.frontendUrl });
|
||||
try {
|
||||
const page = await fresh.newPage();
|
||||
await page.addInitScript(() => localStorage.setItem("kandev.onboarding.completed", "true"));
|
||||
await page.goto(APPEARANCE_PATH);
|
||||
await expect(page.getByRole("radio", { name: "Threads", exact: true })).toBeChecked();
|
||||
expect(await page.evaluate((key) => localStorage.getItem(key), VIEW_STORAGE_KEY)).toBeNull();
|
||||
await page.goto(`/?workspaceId=${seedData.workspaceId}`);
|
||||
await expectThreadsHome(page, seedData.workspaceId);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.2
|
||||
test("keeps Threads draft changes pending until saved", async ({ testPage, apiClient }) => {
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
const threads = testPage.getByRole("radio", { name: "Threads", exact: true });
|
||||
const floatingSave = testPage.getByTestId("settings-floating-save");
|
||||
await expect(threads).toBeVisible();
|
||||
await threads.click();
|
||||
await floatingSave.getByRole("button", { name: "Reset", exact: true }).click();
|
||||
await expect(testPage.getByRole("radio", { name: "Task overview", exact: true })).toBeChecked();
|
||||
await expect(floatingSave).not.toBeVisible();
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("task_overview");
|
||||
|
||||
await threads.click();
|
||||
await testPage.route("**/api/v1/user/settings", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "PATCH" && request.postDataJSON()?.startup_page === "threads") {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Test save failure" }),
|
||||
});
|
||||
} else await route.continue();
|
||||
});
|
||||
const failedSave = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, {
|
||||
predicate: (response) => response.status() === 500,
|
||||
});
|
||||
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
||||
await failedSave;
|
||||
await expect(floatingSave).toHaveAttribute("data-status", "error");
|
||||
await expect(threads).toBeChecked();
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("task_overview");
|
||||
await testPage.unroute("**/api/v1/user/settings");
|
||||
await saveStartupChoice(testPage);
|
||||
expect((await apiClient.getUserSettings()).settings.startup_page).toBe("threads");
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4, 003.6, 003.7
|
||||
test("keeps every Home entry in the selected workspace", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
createdWorkspace = await apiClient.createWorkspace("Threads Home second workspace");
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
createdWorkspace.id,
|
||||
"Threads second workflow",
|
||||
"simple",
|
||||
);
|
||||
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
|
||||
const start = steps.find((step) => step.is_start_step);
|
||||
if (!start) throw new Error("Expected second workspace start step");
|
||||
await apiClient.seedTask(seedData.workspaceId, "First workspace only", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.seedTask(createdWorkspace.id, "Second workspace only", {
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: start.id,
|
||||
});
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
await testPage.getByRole("radio", { name: "Threads", exact: true }).click();
|
||||
await saveStartupChoice(testPage);
|
||||
await testPage.getByTestId("sidebar-settings-gear").click();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
await testPage.getByTestId("view-toggle-list").click();
|
||||
const rows = testPage.getByTestId("tasks-list-row");
|
||||
await expect(rows.filter({ hasText: "First workspace only" })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: "Second workspace only" })).toHaveCount(0);
|
||||
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByTestId(`sidebar-workspace-item-${createdWorkspace.id}`).click();
|
||||
await expectThreadsHome(testPage, createdWorkspace.id);
|
||||
await expect(testPage.getByTestId("threads-empty-state")).toBeVisible();
|
||||
await testPage.getByTestId("view-toggle-list").click();
|
||||
await expect(rows.filter({ hasText: "Second workspace only" })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: "First workspace only" })).toHaveCount(0);
|
||||
|
||||
await testPage.keyboard.press(process.platform === "darwin" ? "Meta+k" : "Control+k");
|
||||
const commands = testPage.getByRole("dialog");
|
||||
await commands.getByRole("combobox").fill("Go to Home");
|
||||
await commands
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Go to Home", { exact: true }) })
|
||||
.click();
|
||||
await expectThreadsHome(testPage, createdWorkspace.id);
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
await testPage.getByRole("link", { name: "Kandev home", exact: true }).click();
|
||||
await expectThreadsHome(testPage, createdWorkspace.id);
|
||||
await testPage.reload();
|
||||
await expectThreadsHome(testPage, createdWorkspace.id);
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5
|
||||
test("preserves explicit destinations with a Threads Home default", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Threads explicit focus",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("Expected a started session");
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
task.session_id,
|
||||
"explicit target session finishes",
|
||||
);
|
||||
await testPage.goto(APPEARANCE_PATH);
|
||||
await testPage.getByRole("radio", { name: "Threads", exact: true }).click();
|
||||
await saveStartupChoice(testPage);
|
||||
await testPage.getByRole("link", { name: "Kandev home", exact: true }).click();
|
||||
await expectThreadsHome(testPage, seedData.workspaceId);
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await testPage.getByTestId("view-toggle-kanban").click();
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await testPage.reload();
|
||||
await expect(kanban.viewToggleKanban).toHaveAttribute("data-state", "on");
|
||||
await kanban.viewTogglePipeline.click();
|
||||
await testPage.reload();
|
||||
await expect(kanban.viewTogglePipeline).toHaveAttribute("data-state", "on");
|
||||
await kanban.viewToggleKanban.click();
|
||||
await testPage.getByTestId("view-toggle-list").click();
|
||||
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
|
||||
await testPage.goto(`/?workspaceId=${seedData.workspaceId}&workflowId=${seedData.workflowId}`);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.pathname === "/" && url.searchParams.get("workflowId") === seedData.workflowId,
|
||||
);
|
||||
const taskUrl = `/t/${task.id}?sessionId=${task.session_id}`;
|
||||
await testPage.goto(taskUrl);
|
||||
await expect(testPage.getByTestId("session-chat")).toBeVisible();
|
||||
await testPage.reload();
|
||||
await expect(testPage).toHaveURL((url) => url.pathname + url.search === taskUrl);
|
||||
const focusUrl = `/threads?workspace=${seedData.workspaceId}&taskId=${task.id}&sessionId=${task.session_id}`;
|
||||
await testPage.goto(focusUrl);
|
||||
await expect(testPage.getByTestId(`thread-column-${task.id}`)).toBeVisible();
|
||||
await testPage.reload();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.searchParams.get("sessionId") === task.session_id,
|
||||
);
|
||||
await testPage.goBack();
|
||||
await expect(testPage).toHaveURL((url) => url.pathname + url.search === taskUrl);
|
||||
await testPage.goForward();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.pathname === "/threads" && url.searchParams.get("taskId") === task.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Startup page", () => {
|
||||
test("saves the last-task choice, resumes it at bare home, and keeps Home on the overview", async ({
|
||||
testPage,
|
||||
|
||||
@@ -95,22 +95,14 @@ async function createParkedTask(
|
||||
}
|
||||
|
||||
test.describe("mobile: parked on background work", () => {
|
||||
let claudeAcpProfileId: string;
|
||||
|
||||
test.beforeAll(async ({ backend, apiClient }) => {
|
||||
test.beforeAll(async ({ backend, seedData }) => {
|
||||
// Seed before changing providers, matching a worker reused by earlier specs.
|
||||
// Per-test cleanup retains this profile, not the suite's provider profile.
|
||||
void seedData;
|
||||
await backend.restart({
|
||||
KANDEV_PARKED_PROBE_INTERVAL: PARKED_PROBE_INTERVAL,
|
||||
KANDEV_MOCK_PROVIDERS: "claude-acp",
|
||||
});
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const claudeAgent = agents.find((agent) => agent.name === "claude-acp");
|
||||
const profileId = claudeAgent?.profiles[0]?.id;
|
||||
if (!profileId) {
|
||||
throw new Error(
|
||||
"E2E seed has no claude-acp mock profile after KANDEV_MOCK_PROVIDERS restart",
|
||||
);
|
||||
}
|
||||
claudeAcpProfileId = profileId;
|
||||
});
|
||||
|
||||
test.afterAll(async ({ backend }) => {
|
||||
@@ -124,11 +116,19 @@ test.describe("mobile: parked on background work", () => {
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// testPage has finished resetting profiles. Create this test's launch
|
||||
// profile afterward so shared-worker cleanup cannot delete its identity.
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const claudeAgent = agents.find((agent) => agent.name === "claude-acp");
|
||||
if (!claudeAgent) throw new Error("E2E seed has no claude-acp mock provider");
|
||||
const profile = await apiClient.createAgentProfile(claudeAgent.id, "Parked background work", {
|
||||
model: "mock-fast",
|
||||
});
|
||||
let parkedSession: SessionPage | undefined;
|
||||
const { sessionId } = await createParkedTask(
|
||||
apiClient,
|
||||
seedData,
|
||||
claudeAcpProfileId,
|
||||
profile.id,
|
||||
"Parked Switcher Turn",
|
||||
async (parkedTaskId) => {
|
||||
await testPage.goto(`/t/${parkedTaskId}`);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
const availabilitySpy = vi.hoisted(() => vi.fn(() => ({ github: true })));
|
||||
const startupPage = vi.hoisted(() => ({ value: "task_overview" }));
|
||||
|
||||
afterEach(() => {
|
||||
startupPage.value = "task_overview";
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-nav-availability", () => ({
|
||||
useNavAvailability: availabilitySpy,
|
||||
@@ -12,8 +17,14 @@ vi.mock("@/components/state-provider", () => ({
|
||||
selector: (state: {
|
||||
workspaces: { activeId: string | null };
|
||||
features: Record<string, boolean>;
|
||||
userSettings: { startupPage: string };
|
||||
}) => unknown,
|
||||
) => selector({ workspaces: { activeId: "ws-1" }, features: { office: false } }),
|
||||
) =>
|
||||
selector({
|
||||
workspaces: { activeId: "ws-1" },
|
||||
features: { office: false },
|
||||
userSettings: { startupPage: startupPage.value },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
@@ -27,6 +38,17 @@ vi.mock("@/lib/plugins/registry", () => ({
|
||||
import { useAppDestinations, useStaticDestinations } from "./use-app-destinations";
|
||||
|
||||
describe("useStaticDestinations", () => {
|
||||
it("updates Home when the saved startup choice changes", () => {
|
||||
const { result, rerender } = renderHook(() => useStaticDestinations("palette"));
|
||||
expect(result.current.find((destination) => destination.id === "home")?.href).toBe(
|
||||
"/?home=overview",
|
||||
);
|
||||
startupPage.value = "threads";
|
||||
rerender();
|
||||
expect(result.current.find((destination) => destination.id === "home")?.href).toBe(
|
||||
"/threads?workspace=ws-1",
|
||||
);
|
||||
});
|
||||
beforeEach(() => availabilitySpy.mockClear());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import type {
|
||||
export function useNavContext(): NavContext {
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const inOffice = useInOffice();
|
||||
return { workspaceId, inOffice };
|
||||
const startupPage = useAppStore((s) => s.userSettings.startupPage);
|
||||
return { workspaceId, inOffice, startupPage };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHomeAffordance } from "./use-home-affordance";
|
||||
const state = {
|
||||
workspaces: { activeId: "ws-1" as string | null },
|
||||
appSidebar: { settingsMode: false, collapsed: false },
|
||||
userSettings: { startupPage: "task_overview" },
|
||||
};
|
||||
|
||||
let inOffice = false;
|
||||
@@ -29,6 +30,7 @@ describe("useHomeAffordance", () => {
|
||||
state.appSidebar.collapsed = false;
|
||||
inOffice = false;
|
||||
modeUnknown = false;
|
||||
state.userSettings.startupPage = "task_overview";
|
||||
});
|
||||
|
||||
it("defaults to a phone-only crumb pointing at the workspace overview", () => {
|
||||
@@ -38,6 +40,15 @@ describe("useHomeAffordance", () => {
|
||||
expect(result.current.href).toBe("/?home=overview&workspaceId=ws-1");
|
||||
});
|
||||
|
||||
it("uses saved Threads for both phone and settings Home", () => {
|
||||
state.userSettings.startupPage = "threads";
|
||||
const { result, rerender } = renderHook(() => useHomeAffordance());
|
||||
expect(result.current).toMatchObject({ mode: "phone", href: "/threads?workspace=ws-1" });
|
||||
state.appSidebar.settingsMode = true;
|
||||
rerender();
|
||||
expect(result.current).toMatchObject({ mode: "always", href: "/threads?workspace=ws-1" });
|
||||
});
|
||||
|
||||
it("drops the workspace param when no workspace is active", () => {
|
||||
state.workspaces.activeId = null;
|
||||
|
||||
|
||||
@@ -65,8 +65,20 @@ describe("useTaskListingView", () => {
|
||||
expect(result.current.effectiveView).toBe("kanban");
|
||||
await waitFor(() => {
|
||||
expect(mocks.setUserSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kanbanViewMode: null, loaded: true }),
|
||||
expect.objectContaining({ kanbanViewMode: null, loaded: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the portable Threads default and readiness when changing the device view", () => {
|
||||
mocks.state!.userSettings.startupPage = "threads";
|
||||
mocks.state!.userSettings.loaded = true;
|
||||
const { result } = renderHook(() => useTaskListingView());
|
||||
act(() => result.current.setView("pipeline"));
|
||||
expect(result.current.preferredView).toBe("pipeline");
|
||||
expect(mocks.setUserSettings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kanbanViewMode: "graph2", startupPage: "threads", loaded: true }),
|
||||
);
|
||||
expect(window.localStorage.getItem(TASK_LISTING_VIEW_STORAGE_KEY)).toBe('"pipeline"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useTaskListingView() {
|
||||
useEffect(() => {
|
||||
const kanbanViewMode = effectiveView === "pipeline" ? "graph2" : null;
|
||||
if (userSettings.kanbanViewMode === kanbanViewMode) return;
|
||||
setUserSettings({ ...userSettings, kanbanViewMode, loaded: true });
|
||||
setUserSettings({ ...userSettings, kanbanViewMode });
|
||||
}, [effectiveView, setUserSettings, userSettings]);
|
||||
|
||||
const setView = useCallback((view: TaskListingView) => {
|
||||
|
||||
@@ -34,19 +34,19 @@ import {
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon";
|
||||
import { linkToOfficeHome, linkToTaskOverview, linkToTasks, linkToThreads } from "@/lib/links";
|
||||
import { linkToTaskOverview, linkToTasks, linkToThreads } from "@/lib/links";
|
||||
import { resolveHomeHref } from "./workspace-home";
|
||||
import { EVERYWHERE, MENU_AND_PALETTE, SIDEBAR_AND_MENU } from "./surface-policy";
|
||||
import type { Destination, NavContext } from "./types";
|
||||
|
||||
/**
|
||||
* Where "home" is for the current mode and workspace — the office dashboard
|
||||
* inside Office, the workspace task overview otherwise. Exported so the
|
||||
* inside Office, the chosen workspace home otherwise. Exported so the
|
||||
* topbar's home crumb (`useHomeAffordance`) and the manifest's home entry
|
||||
* resolve one rule and cannot disagree.
|
||||
*/
|
||||
export function homeDestinationHref(ctx: NavContext): string {
|
||||
if (!ctx.inOffice) return linkToTaskOverview({ workspaceId: ctx.workspaceId ?? undefined });
|
||||
return linkToOfficeHome({ workspaceId: ctx.workspaceId ?? undefined });
|
||||
return resolveHomeHref(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,18 +66,16 @@ export const APP_DESTINATIONS: Destination[] = [
|
||||
icon: IconHome,
|
||||
section: "primary",
|
||||
href: homeDestinationHref,
|
||||
// The sidebar's primary nav still owns "go home" on desktop; the mobile
|
||||
// menu offers it so shells without kanban's brand link (Settings, Office,
|
||||
// plugin pages) keep a phone home row. Kanban's drawer opts out via
|
||||
// omitSections.
|
||||
// The sidebar owns desktop Home; shared navigation and listing menus
|
||||
// expose the same destination on phones.
|
||||
surfaces: MENU_AND_PALETTE,
|
||||
palette: {
|
||||
id: "nav-home",
|
||||
labelKey: "common:commandGoToHome",
|
||||
keywordsKey: "common:commandGoToHomeKeywords",
|
||||
// The palette has always sent users to the workspace-less overview; a
|
||||
// manifest refactor is the wrong place to change where a command lands.
|
||||
href: () => linkToTaskOverview(),
|
||||
// Existing startup choices retain the palette's workspace-less overview.
|
||||
href: (ctx) =>
|
||||
ctx.startupPage === "threads" ? homeDestinationHref(ctx) : linkToTaskOverview(),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,18 @@ function ids(destinations: { id: string }[]): string[] {
|
||||
}
|
||||
|
||||
describe("resolveDestinations", () => {
|
||||
it.each(["palette", "mobileMenu"] as const)("honors fixed Threads Home in %s", (surface) => {
|
||||
const home = resolveDestinations({ surface, ctx: { ...KANBAN, startupPage: "threads" } }).find(
|
||||
(destination) => destination.id === "home",
|
||||
);
|
||||
expect(home?.href).toBe("/threads?workspace=ws-1");
|
||||
const officeHome = resolveDestinations({
|
||||
surface,
|
||||
ctx: { ...OFFICE, startupPage: "threads" },
|
||||
}).find((destination) => destination.id === "home");
|
||||
expect(officeHome?.href).toBe("/office?workspaceId=ws-office");
|
||||
});
|
||||
|
||||
it("offers the sidebar's insight and integration destinations, in manifest order", () => {
|
||||
const resolved = resolveDestinations({
|
||||
surface: "sidebar",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* `hooks/use-app-destinations.ts`, which keeps the manifest unit-testable.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { StartupPage } from "@/lib/types/http-user-settings";
|
||||
|
||||
/**
|
||||
* Where a destination may be offered; every destination lists its surfaces
|
||||
@@ -55,6 +56,7 @@ export type DestinationIcon = ComponentType<{ className?: string }>;
|
||||
export type NavContext = {
|
||||
workspaceId: string | null;
|
||||
inOffice: boolean;
|
||||
startupPage?: StartupPage;
|
||||
};
|
||||
|
||||
export type DestinationHref = string | ((ctx: NavContext) => string);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { homeDestinationHref } from "./core-destinations";
|
||||
import { workspaceHomeHref } from "./workspace-home";
|
||||
import type { StartupPage } from "@/lib/types/http-user-settings";
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4, 003.6, 003.7
|
||||
describe("Home destination", () => {
|
||||
it.each<StartupPage>(["task_overview", "last_task", "threads"])(
|
||||
"preserves Office and no-workspace fallback for %s",
|
||||
(startupPage) => {
|
||||
expect(workspaceHomeHref({ id: "office/1", office_workflow_id: "wf" }, startupPage)).toBe(
|
||||
"/office?workspaceId=office%2F1",
|
||||
);
|
||||
expect(workspaceHomeHref(undefined, startupPage)).toBe("/?home=overview");
|
||||
},
|
||||
);
|
||||
|
||||
it.each<StartupPage>(["task_overview", "last_task", "threads"])(
|
||||
"shares workspace-aware policy with the manifest for %s",
|
||||
(startupPage) => {
|
||||
const expected =
|
||||
startupPage === "threads"
|
||||
? "/threads?workspace=ws%2F2%26x"
|
||||
: "/?home=overview&workspaceId=ws%2F2%26x";
|
||||
expect(workspaceHomeHref({ id: "ws/2&x" }, startupPage)).toBe(expected);
|
||||
expect(homeDestinationHref({ workspaceId: "ws/2&x", inOffice: false, startupPage })).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(homeDestinationHref({ workspaceId: "ws/2&x", inOffice: true, startupPage })).toBe(
|
||||
"/office?workspaceId=ws%2F2%26x",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,29 @@
|
||||
import { linkToOfficeHome, linkToTaskOverview } from "@/lib/links";
|
||||
import { linkToOfficeHome, linkToTaskOverview, linkToThreads } from "@/lib/links";
|
||||
import { isOfficeWorkspace, type ModeWorkspace } from "@/lib/state/slices/workspace/selectors";
|
||||
import type { StartupPage } from "@/lib/types/http-user-settings";
|
||||
|
||||
export function resolveHomeHref({
|
||||
workspaceId,
|
||||
inOffice,
|
||||
startupPage = "task_overview",
|
||||
}: {
|
||||
workspaceId: string | null;
|
||||
inOffice: boolean;
|
||||
startupPage?: StartupPage;
|
||||
}): string {
|
||||
if (inOffice) return linkToOfficeHome({ workspaceId: workspaceId ?? undefined });
|
||||
if (workspaceId && startupPage === "threads") return linkToThreads(workspaceId);
|
||||
return linkToTaskOverview({ workspaceId: workspaceId ?? undefined });
|
||||
}
|
||||
|
||||
/** Returns the home destination for a workspace without depending on UI code. */
|
||||
export function workspaceHomeHref(workspace: ModeWorkspace | undefined): string {
|
||||
if (!workspace) return linkToTaskOverview();
|
||||
if (!isOfficeWorkspace(workspace)) return linkToTaskOverview({ workspaceId: workspace.id });
|
||||
return linkToOfficeHome({ workspaceId: workspace.id });
|
||||
export function workspaceHomeHref(
|
||||
workspace: ModeWorkspace | undefined,
|
||||
startupPage?: StartupPage,
|
||||
): string {
|
||||
return resolveHomeHref({
|
||||
workspaceId: workspace?.id ?? null,
|
||||
inOffice: isOfficeWorkspace(workspace),
|
||||
startupPage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,11 +55,21 @@ describe("user settings revision ordering", () => {
|
||||
|
||||
describe("startup page user settings", () => {
|
||||
it("normalizes startup page preferences", () => {
|
||||
expect(parseStartupPage("threads")).toBe("threads");
|
||||
expect(parseStartupPage("last_task")).toBe("last_task");
|
||||
expect(parseStartupPage(undefined)).toBe("task_overview");
|
||||
expect(parseStartupPage("future_value")).toBe("task_overview");
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3
|
||||
it("maps Threads and preserves it when a later settings patch omits startup page", () => {
|
||||
const current = mapUserSettingsData({ startup_page: "threads" });
|
||||
expect(current.startupPage).toBe("threads");
|
||||
expect(mapUserSettingsData({ tasks_list_show_details: true }, current).startupPage).toBe(
|
||||
"threads",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults startup page and maps the last-task choice", () => {
|
||||
expect(mapUserSettingsResponse(null).startupPage).toBe("task_overview");
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ export function parseMCPTaskAgentProfileDefault(
|
||||
|
||||
/** Parses the startup page preference, defaulting to "task_overview". */
|
||||
export function parseStartupPage(value: string | undefined): StartupPage {
|
||||
return value === "last_task" ? "last_task" : "task_overview";
|
||||
return value === "last_task" || value === "threads" ? value : "task_overview";
|
||||
}
|
||||
|
||||
/** Parses the LSP status location, defaulting to "toolbar". */
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RecentTaskEntry } from "./recent-tasks";
|
||||
import { isExplicitHomeDestination, resolveStartupTaskId } from "./startup-page";
|
||||
import {
|
||||
isExplicitHomeDestination,
|
||||
resolveStartupTaskId,
|
||||
resolveStartupListingRedirect,
|
||||
} from "./startup-page";
|
||||
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
@@ -14,6 +18,47 @@ function recentTask(taskId: string, workspaceId: string): RecentTaskEntry {
|
||||
}
|
||||
|
||||
describe("startup page resolution", () => {
|
||||
// Test-only contract coverage for the existing startup fallbacks.
|
||||
it("leaves onboarding reachable without a workspace for fixed Threads", () => {
|
||||
expect(
|
||||
resolveStartupListingRedirect({
|
||||
startupPage: "threads",
|
||||
preferredView: "kanban",
|
||||
searchParams: new URLSearchParams(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("lets explicit overview restore the remembered List despite fixed Threads", () => {
|
||||
expect(
|
||||
resolveStartupListingRedirect({
|
||||
startupPage: "threads",
|
||||
preferredView: "list",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
searchParams: new URLSearchParams("home=overview"),
|
||||
}),
|
||||
).toBe(`/tasks?workspace=${WORKSPACE_ID}`);
|
||||
});
|
||||
|
||||
it("keeps fixed Threads independent of recent tasks and missing listing memory", () => {
|
||||
expect(
|
||||
resolveStartupTaskId({
|
||||
startupPage: "threads",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
recentTasks: [recentTask("local", WORKSPACE_ID)],
|
||||
hasExplicitDestination: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStartupListingRedirect({
|
||||
startupPage: "threads",
|
||||
preferredView: "kanban",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
searchParams: new URLSearchParams(`workspaceId=${WORKSPACE_ID}`),
|
||||
}),
|
||||
).toBe(`/threads?workspace=${WORKSPACE_ID}`);
|
||||
});
|
||||
|
||||
it("resumes the newest recent task in the active workspace", () => {
|
||||
expect(
|
||||
resolveStartupTaskId({
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { findMostRecentTaskForWorkspace, type RecentTaskEntry } from "./recent-tasks";
|
||||
import type { StartupPage } from "./types/http-user-settings";
|
||||
import { linkToTasks, linkToThreads } from "./links";
|
||||
import {
|
||||
resolveHomeTaskListingRedirect,
|
||||
type TaskListingView,
|
||||
} from "./task-listing/view-preference";
|
||||
|
||||
type StartupTaskResolutionInput = {
|
||||
startupPage: StartupPage;
|
||||
@@ -32,3 +37,35 @@ export function resolveStartupTaskId({
|
||||
if (startupPage !== "last_task" || hasExplicitDestination) return null;
|
||||
return findMostRecentTaskForWorkspace(recentTasks, workspaceId)?.taskId ?? null;
|
||||
}
|
||||
|
||||
export function resolveStartupListingRedirect({
|
||||
startupPage,
|
||||
preferredView,
|
||||
workspaceId,
|
||||
searchParams,
|
||||
initialTaskId,
|
||||
initialSessionId,
|
||||
}: {
|
||||
startupPage: StartupPage;
|
||||
preferredView: TaskListingView;
|
||||
workspaceId?: string;
|
||||
searchParams: URLSearchParams;
|
||||
initialTaskId?: string;
|
||||
initialSessionId?: string;
|
||||
}): string | null {
|
||||
if (
|
||||
initialTaskId ||
|
||||
initialSessionId ||
|
||||
searchParams.get("taskId") ||
|
||||
searchParams.get("sessionId") ||
|
||||
searchParams.get("workflowId")
|
||||
)
|
||||
return null;
|
||||
// Explicit overview skips the fixed default but still restores the remembered listing.
|
||||
if (startupPage === "threads" && !isExplicitHomeDestination(searchParams)) {
|
||||
return workspaceId ? linkToThreads(workspaceId) : null;
|
||||
}
|
||||
const routedView = resolveHomeTaskListingRedirect(preferredView, initialTaskId, initialSessionId);
|
||||
if (routedView === "threads") return linkToThreads(workspaceId);
|
||||
return routedView === "list" ? linkToTasks(workspaceId) : null;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ describe("resolveTaskListingNavigation", () => {
|
||||
});
|
||||
|
||||
describe("linkToThreads", () => {
|
||||
it("preserves a focused session only alongside its task", () => {
|
||||
expect(linkToThreads(WORKSPACE_ID, "task-1", "session-1")).toBe(
|
||||
`${WORKSPACE_QUERY}&taskId=task-1&sessionId=session-1`,
|
||||
);
|
||||
expect(linkToThreads(WORKSPACE_ID, undefined, "session-1")).toBe(WORKSPACE_QUERY);
|
||||
});
|
||||
it("keeps the deck link plain when no task is focused", () => {
|
||||
expect(linkToThreads()).toBe("/threads");
|
||||
expect(linkToThreads(WORKSPACE_ID)).toBe(WORKSPACE_QUERY);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WorkspaceId } from "./ids";
|
||||
|
||||
export type MCPTaskAgentProfileDefault = "current_task" | "workspace_default";
|
||||
export type StartupPage = "task_overview" | "last_task";
|
||||
export type StartupPage = "task_overview" | "last_task" | "threads";
|
||||
export type LspStatusLocation = "toolbar" | "status_bar";
|
||||
export type LastSeenDisplay = "absolute" | "relative";
|
||||
|
||||
|
||||
@@ -85,6 +85,15 @@ describe("agent profile recent-use websocket sync", () => {
|
||||
});
|
||||
|
||||
describe("startup page websocket sync", () => {
|
||||
it("retains Threads through live updates that omit the startup choice", () => {
|
||||
const store = makeStore();
|
||||
const handler = registerUsersHandlers(store)["user.settings.updated"];
|
||||
handler?.(userSettingsMessage({ startup_page: "threads" }));
|
||||
expect(store.getState().userSettings.startupPage).toBe("threads");
|
||||
handler?.(userSettingsMessage({ tasks_list_show_details: true }));
|
||||
expect(store.getState().userSettings.startupPage).toBe("threads");
|
||||
});
|
||||
|
||||
it("applies startup page preferences and normalizes unknown values", () => {
|
||||
const store = makeStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import type { AppState, HydrationState } from "@/lib/state/store";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import type { StartupPage } from "@/lib/types/http-user-settings";
|
||||
import { mapUserSettingsData } from "@/lib/ssr/user-settings";
|
||||
import { TASK_LISTING_VIEW_STORAGE_KEY } from "@/lib/task-listing/view-preference";
|
||||
import { KanbanRoute, type KanbanRouteSelection } from "./kanban-route";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
router: { replace: vi.fn() },
|
||||
listWorkspaces: vi.fn(),
|
||||
fetchUserSettings: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
listRepositories: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/domains/workspace-api", () => ({
|
||||
listWorkspaces: mocks.listWorkspaces,
|
||||
listRepositories: mocks.listRepositories,
|
||||
}));
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({ fetchUserSettings: mocks.fetchUserSettings }));
|
||||
vi.mock("@/lib/api/domains/kanban-api", () => ({ listWorkflows: mocks.listWorkflows }));
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
useRouter: () => mocks.router,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/components/kanban-with-preview", () => ({ KanbanWithPreview: () => null }));
|
||||
vi.mock("@/components/onboarding-dialog", () => ({ OnboardingDialog: () => null }));
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => ({ isMobile: false }),
|
||||
}));
|
||||
|
||||
function workspace(id: string, officeWorkflowId: string | null = null) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: null,
|
||||
owner_id: "owner",
|
||||
default_executor_id: null,
|
||||
default_environment_id: null,
|
||||
default_agent_profile_id: null,
|
||||
default_config_agent_profile_id: null,
|
||||
office_workflow_id: officeWorkflowId,
|
||||
created_at: "2026-09-10",
|
||||
updated_at: "2026-09-10",
|
||||
};
|
||||
}
|
||||
function settings(startupPage: StartupPage = "threads") {
|
||||
return {
|
||||
settings: {
|
||||
startup_page: startupPage,
|
||||
workspace_id: "ws-1",
|
||||
workflow_filter_id: "",
|
||||
repository_ids: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
let store: StoreApi<AppState>;
|
||||
function CaptureStore() {
|
||||
store = useAppStoreApi();
|
||||
return null;
|
||||
}
|
||||
function Subject({
|
||||
route = {},
|
||||
initialState,
|
||||
}: {
|
||||
route?: KanbanRouteSelection;
|
||||
initialState?: HydrationState;
|
||||
}) {
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<CaptureStore />
|
||||
<KanbanRoute route={route} fallback={<p role="status">Loading</p>} />
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.localStorage.clear();
|
||||
mocks.listWorkspaces.mockResolvedValue({ workspaces: [workspace("ws-1")], total: 1 });
|
||||
mocks.fetchUserSettings.mockResolvedValue(settings());
|
||||
mocks.listWorkflows.mockResolvedValue({ workflows: [] });
|
||||
mocks.listRepositories.mockResolvedValue({ repositories: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4, 003.6, 003.7
|
||||
describe("Home startup bootstrap", () => {
|
||||
it("waits for authoritative settings before making one redirect, even without workflows", async () => {
|
||||
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"list"');
|
||||
const pending = deferred<ReturnType<typeof settings>>();
|
||||
mocks.fetchUserSettings.mockReturnValue(pending.promise);
|
||||
render(<Subject />);
|
||||
expect(mocks.router.replace).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
pending.resolve(settings());
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/threads?workspace=ws-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors boot settings without fetching when board state is already hydrated", async () => {
|
||||
render(
|
||||
<Subject
|
||||
initialState={{
|
||||
workspaces: { activeId: "ws-1", items: [workspace("ws-1")] },
|
||||
userSettings: mapUserSettingsData(settings().settings),
|
||||
workflows: {
|
||||
activeId: "wf-1",
|
||||
items: [
|
||||
{ id: "wf-1", workspaceId: "ws-1", name: "Main", description: null, sortOrder: 0 },
|
||||
],
|
||||
},
|
||||
kanban: { ...defaultState.kanban, workflowId: "wf-1" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/threads?workspace=ws-1"),
|
||||
);
|
||||
expect(mocks.fetchUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a late response after the requested workspace changes", async () => {
|
||||
const old = deferred<ReturnType<typeof settings>>();
|
||||
mocks.fetchUserSettings.mockReturnValueOnce(old.promise).mockResolvedValue(settings());
|
||||
mocks.listWorkspaces.mockResolvedValue({
|
||||
workspaces: [workspace("ws-1"), workspace("ws-2")],
|
||||
total: 2,
|
||||
});
|
||||
const { rerender } = render(<Subject route={{ workspaceId: "ws-1" }} />);
|
||||
rerender(<Subject route={{ workspaceId: "ws-2" }} />);
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/threads?workspace=ws-2"),
|
||||
);
|
||||
await act(async () => {
|
||||
old.resolve(settings("last_task"));
|
||||
});
|
||||
expect(store.getState().workspaces.activeId).toBe("ws-2");
|
||||
expect(store.getState().userSettings.startupPage).toBe("threads");
|
||||
expect(mocks.router.replace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a pending bootstrap when the route unmounts", async () => {
|
||||
const pending = deferred<ReturnType<typeof settings>>();
|
||||
mocks.fetchUserSettings.mockReturnValue(pending.promise);
|
||||
const { unmount } = render(<Subject />);
|
||||
unmount();
|
||||
await act(async () => {
|
||||
pending.resolve(settings());
|
||||
});
|
||||
expect(store.getState().workspaces.activeId).toBeNull();
|
||||
expect(store.getState().userSettings.loaded).toBe(false);
|
||||
expect(mocks.router.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5
|
||||
describe("Home readiness after hydrated startup", () => {
|
||||
it.each([null, "wf-2"])(
|
||||
"stays ready for live workflow filter %s but gates a new route",
|
||||
async (id) => {
|
||||
const pending = deferred<ReturnType<typeof settings>>();
|
||||
mocks.fetchUserSettings.mockReturnValue(pending.promise);
|
||||
const { rerender } = render(
|
||||
<Subject
|
||||
initialState={{
|
||||
workspaces: { activeId: "ws-1", items: [workspace("ws-1")] },
|
||||
userSettings: mapUserSettingsData(settings("task_overview").settings),
|
||||
workflows: {
|
||||
activeId: "wf-1",
|
||||
items: ["wf-1", "wf-2"].map((workflowId) => ({
|
||||
id: workflowId,
|
||||
workspaceId: "ws-1",
|
||||
name: workflowId,
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
})),
|
||||
},
|
||||
kanban: { ...defaultState.kanban, workflowId: "wf-1" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
act(() => store.getState().setActiveWorkflow(id));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(mocks.fetchUserSettings).not.toHaveBeenCalled();
|
||||
|
||||
mocks.listWorkspaces.mockResolvedValue({
|
||||
workspaces: [workspace("ws-1"), workspace("ws-2")],
|
||||
total: 2,
|
||||
});
|
||||
rerender(<Subject route={{ workspaceId: "ws-2" }} />);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
await act(async () => pending.resolve(settings("task_overview")));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
expect(store.getState().workspaces.activeId).toBe("ws-2");
|
||||
expect(mocks.router.replace).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Home startup fallback", () => {
|
||||
it.each(["blocked", "malformed"])("uses fixed Threads with %s listing storage", async (mode) => {
|
||||
if (mode === "blocked") {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage denied");
|
||||
});
|
||||
} else window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, "not-json");
|
||||
render(<Subject />);
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/threads?workspace=ws-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Office priority over fixed Threads", async () => {
|
||||
mocks.listWorkspaces.mockResolvedValue({
|
||||
workspaces: [workspace("ws-1", "office-wf")],
|
||||
total: 1,
|
||||
});
|
||||
render(<Subject initialState={{ features: { ...defaultState.features, office: true } }} />);
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/office?workspaceId=ws-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("settles an empty workspace list without sending onboarding to Threads", async () => {
|
||||
mocks.listWorkspaces.mockResolvedValue({ workspaces: [], total: 0 });
|
||||
render(<Subject />);
|
||||
await waitFor(() => expect(store.getState().userSettings.loaded).toBe(true));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(mocks.router.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles failed bootstrap with the existing fallback", async () => {
|
||||
mocks.listWorkspaces.mockRejectedValue(new Error("offline"));
|
||||
mocks.fetchUserSettings.mockRejectedValue(new Error("offline"));
|
||||
render(<Subject />);
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
expect(store.getState().userSettings.startupPage).toBe("task_overview");
|
||||
expect(mocks.router.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to remembered List when the settings fetch fails", async () => {
|
||||
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"list"');
|
||||
mocks.fetchUserSettings.mockRejectedValue(new Error("offline"));
|
||||
render(<Subject />);
|
||||
await waitFor(() =>
|
||||
expect(mocks.router.replace).toHaveBeenCalledExactlyOnceWith("/tasks?workspace=ws-1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { PageClient } from "@/app/page-client";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
@@ -90,11 +90,25 @@ function useKanbanWorkspaceMismatchRedirect(route: KanbanRouteSelection): string
|
||||
*/
|
||||
export function useKanbanRouteBootstrap(route: KanbanRouteSelection, skip: boolean) {
|
||||
const store = useAppStoreApi();
|
||||
const selection = useMemo(
|
||||
() => ({ workspaceId: route.workspaceId, workflowId: route.workflowId }),
|
||||
[route.workspaceId, route.workflowId],
|
||||
);
|
||||
const [completedSelection, setCompletedSelection] = useState<typeof selection | null>(null);
|
||||
const hydrated = useAppStore(
|
||||
(state) => state.userSettings.loaded && hasHydratedKanbanRouteState(state, selection),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
promoteLegacyWorkspaceSelection(store.getState().workspaces.items);
|
||||
if (skip) return;
|
||||
if (hasHydratedKanbanRouteState(store.getState(), route)) return;
|
||||
if (
|
||||
store.getState().userSettings.loaded &&
|
||||
hasHydratedKanbanRouteState(store.getState(), selection)
|
||||
) {
|
||||
setCompletedSelection(selection);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -111,7 +125,7 @@ export function useKanbanRouteBootstrap(route: KanbanRouteSelection, skip: boole
|
||||
promoteLegacyWorkspaceSelection(workspaceItems);
|
||||
const activeWorkspaceId = resolveKanbanRouteWorkspaceId(
|
||||
workspaceItems,
|
||||
route.workspaceId,
|
||||
selection.workspaceId,
|
||||
readActiveWorkspaceCookie(),
|
||||
settingsWorkspaceId,
|
||||
);
|
||||
@@ -144,7 +158,7 @@ export function useKanbanRouteBootstrap(route: KanbanRouteSelection, skip: boole
|
||||
if (cancelled) return;
|
||||
|
||||
const workflowId = resolveDesiredWorkflowId({
|
||||
activeWorkflowId: route.workflowId ?? null,
|
||||
activeWorkflowId: selection.workflowId ?? null,
|
||||
settingsWorkflowId,
|
||||
workspaceWorkflows: workflowsResponse.workflows,
|
||||
});
|
||||
@@ -163,11 +177,16 @@ export function useKanbanRouteBootstrap(route: KanbanRouteSelection, skip: boole
|
||||
store.getState().setRepositories(activeWorkspaceId, repositoriesResponse.repositories);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
void bootstrap().then(() => {
|
||||
if (!cancelled) setCompletedSelection(selection);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [route.workspaceId, route.workflowId, skip, store]);
|
||||
}, [selection, skip, store]);
|
||||
|
||||
// Completion is tied to this route request, including empty and failed fetches.
|
||||
return hydrated || completedSelection === selection;
|
||||
}
|
||||
|
||||
function mapWorkflowItem(workflow: Workflow) {
|
||||
@@ -192,14 +211,14 @@ export function KanbanRoute({
|
||||
fallback: React.ReactNode;
|
||||
}) {
|
||||
const redirectHref = useKanbanWorkspaceMismatchRedirect(route);
|
||||
useKanbanRouteBootstrap(route, redirectHref !== null);
|
||||
const ready = useKanbanRouteBootstrap(route, redirectHref !== null);
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
|
||||
if (redirectHref) return <>{fallback}</>;
|
||||
if (redirectHref || !ready) return <>{fallback}</>;
|
||||
|
||||
return (
|
||||
<PageClient
|
||||
workspaceId={route.workspaceId ?? activeWorkspaceId ?? undefined}
|
||||
workspaceId={activeWorkspaceId ?? undefined}
|
||||
initialTaskId={route.taskId}
|
||||
initialSessionId={route.sessionId}
|
||||
/>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"chooseAMonospaceFontForThe": "Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.",
|
||||
"chooseHowDeepTheSettingsMenuGoes": "Choose whether the settings menu lists only pages, or expands Workspaces, Agents and Executors into what they contain.",
|
||||
"chooseTheLanguageUsedAcrossThe": "Choose the language used across the Kandev interface",
|
||||
"chooseWhatOpensWhenKandevStarts": "Choose what opens when Kandev starts or you visit its bare home page",
|
||||
"chooseWhatOpensWhenKandevStarts": "Choose a startup destination. Select Threads to use it for Home too.",
|
||||
"chooseWhichTranscriptNavigationControls": "Choose which transcript navigation controls appear, including the desktop-only sticky prompt bar",
|
||||
"clearSettingsSearch": "Clear settings search",
|
||||
"clearShortcut": "Clear shortcut",
|
||||
@@ -215,7 +215,7 @@
|
||||
"discoveryAliasesResourceMetrics": "metrics,CPU,memory,disk,status bar",
|
||||
"discoveryAliasesRichOutputMotion": "chart animation,graphs,performance,reduced motion",
|
||||
"discoveryAliasesSettingsMenu": "sidebar,navigation,tree,accordion,menu",
|
||||
"discoveryAliasesStartupPage": "home page,start page,launch page",
|
||||
"discoveryAliasesStartupPage": "home page,start page,launch page,threads,default home,home view",
|
||||
"discoveryAliasesStatusBar": "bottom bar,status drawer,connection status,appearance",
|
||||
"discoveryAliasesSubmitShortcut": "send key,enter to send,command enter",
|
||||
"discoveryAliasesTerminalFont": "font family,typeface,monospace",
|
||||
@@ -556,7 +556,7 @@
|
||||
"resourceMetrics": "Resource Metrics",
|
||||
"richOutputChartMotion": "Rich-output Chart Motion",
|
||||
"restoreTheOriginalBuiltInLayout": "Restore the original built-in layout and discard its override.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Resume the most recently opened task in this workspace on this device.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Resume the most recently opened task in this workspace on this device at startup. Home still uses the remembered listing.",
|
||||
"retrySave": "Retry save",
|
||||
"retryReset": "Retry reset",
|
||||
"runningSprites": "Running Sprites",
|
||||
@@ -636,7 +636,8 @@
|
||||
"spriteHealthUnhealthy": "Unhealthy",
|
||||
"spriteHealthUnknown": "Unknown",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev provides ephemeral cloud sandboxes for running agents remotely.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Start on your saved Kanban, Pipeline, or List view.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Open the last Kanban, Pipeline, List, or Threads view used on this device.",
|
||||
"openThreadsOnStartupAndHome": "Open Threads on startup and whenever you go Home in the selected workspace, regardless of the last listing view.",
|
||||
"startupPage": "Startup Page",
|
||||
"statusBar": "Status Bar",
|
||||
"submitShortcut": "Submit Shortcut",
|
||||
@@ -659,7 +660,7 @@
|
||||
"testNotificationBody": "If you can read this, browser notifications are working.",
|
||||
"testNotificationTitle": "Test notification",
|
||||
"theBuiltInDefaultLayoutWill": "The built-in Default layout will become the default.",
|
||||
"thisAppliesWhenKandevStarts": "This applies when Kandev starts or you open its bare home page. Home, Back, and workflow navigation always open the task overview.",
|
||||
"thisAppliesWhenKandevStarts": "This choice follows your user across devices. Explicit links keep their destination, and Office workspaces keep their Office Home.",
|
||||
"thisLayoutIsUsedAsThe": "This layout is used as the starting layout for new tasks.",
|
||||
"thisPageHasUnsavedChangesSave": "This page has unsaved changes. Save them, discard them, or continue editing.",
|
||||
"thisProfileWillBeRemovedWhen": "This profile will be removed when you save.",
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"chooseAMonospaceFontForThe": "Ćĥōōśē à ḿōńōśƥàćē ƒōńţ ƒōŕ ţĥē ţēŕḿĩńàĺ. Ńēŕď Ƒōńţś ĩńćĺũďē ĩćōńś ƒōŕ ĆĹĨ ţōōĺś.",
|
||||
"chooseHowDeepTheSettingsMenuGoes": "Ćĥōōśē ŵĥēţĥēŕ ţĥē śēţţĩńĝś ḿēńũ ĺĩśţś ōńĺŷ ƥàĝēś, ōŕ ēxƥàńďś Ŵōŕķśƥàćēś, Àĝēńţś àńď Ēxēćũţōŕś ĩńţō ŵĥàţ ţĥēŷ ćōńţàĩń.",
|
||||
"chooseTheLanguageUsedAcrossThe": "Ćĥōōśē ţĥē ĺàńĝũàĝē ũśēď àćŕōśś ţĥē Ķàńďēv ĩńţēŕƒàćē",
|
||||
"chooseWhatOpensWhenKandevStarts": "Ćĥōōśē ŵĥàţ ōƥēńś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ vĩśĩţ ĩţś ƀàŕē ĥōḿē ƥàĝē",
|
||||
"chooseWhatOpensWhenKandevStarts": "Ćĥōōśē à śţàŕţũƥ ďēśţĩńàţĩōń. Śēĺēćţ Ţĥŕēàďś ţō ũśē ĩţ ƒōŕ Ĥōḿē ţōō.",
|
||||
"chooseWhichTranscriptNavigationControls": "Ćĥōōśē ŵĥĩćĥ ţŕàńśćŕĩƥţ ńàvĩĝàţĩōń ćōńţŕōĺś àƥƥēàŕ, ĩńćĺũďĩńĝ ţĥē ďēśķţōƥ-ōńĺŷ śţĩćķŷ ƥŕōḿƥţ ƀàŕ",
|
||||
"clearSettingsSearch": "Ćĺēàŕ śēţţĩńĝś śēàŕćĥ",
|
||||
"clearShortcut": "Ćĺēàŕ śĥōŕţćũţ",
|
||||
@@ -215,7 +215,7 @@
|
||||
"discoveryAliasesResourceMetrics": "ḿēţŕĩćś,ĆƤŨ,ḿēḿōŕŷ,ďĩśķ,śţàţũś ƀàŕ",
|
||||
"discoveryAliasesRichOutputMotion": "ćĥàŕţ àńĩḿàţĩōń,ĝŕàƥĥś,ƥēŕƒōŕḿàńćē,ŕēďũćēď ḿōţĩōń",
|
||||
"discoveryAliasesSettingsMenu": "śĩďēƀàŕ,ńàvĩĝàţĩōń,ţŕēē,àććōŕďĩōń,ḿēńũ",
|
||||
"discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē",
|
||||
"discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē,ţĥŕēàďś,ďēƒàũĺţ ĥōḿē,ĥōḿē vĩēŵ",
|
||||
"discoveryAliasesStatusBar": "ƀōţţōḿ ƀàŕ,śţàţũś ďŕàŵēŕ,ćōńńēćţĩōń śţàţũś,àƥƥēàŕàńćē",
|
||||
"discoveryAliasesSubmitShortcut": "śēńď ķēŷ,ēńţēŕ ţō śēńď,ćōḿḿàńď ēńţēŕ",
|
||||
"discoveryAliasesTerminalFont": "ƒōńţ ƒàḿĩĺŷ,ţŷƥēƒàćē,ḿōńōśƥàćē",
|
||||
@@ -556,7 +556,7 @@
|
||||
"resourceMetrics": "Ŕēśōũŕćē Ḿēţŕĩćś",
|
||||
"richOutputChartMotion": "Ŕĩćĥ-ōũţƥũţ Ćĥàŕţ Ḿōţĩōń",
|
||||
"restoreTheOriginalBuiltInLayout": "Ŕēśţōŕē ţĥē ōŕĩĝĩńàĺ ƀũĩĺţ-ĩń ĺàŷōũţ àńď ďĩśćàŕď ĩţś ōvēŕŕĩďē.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Ŕēśũḿē ţĥē ḿōśţ ŕēćēńţĺŷ ōƥēńēď ţàśķ ĩń ţĥĩś ŵōŕķśƥàćē ōń ţĥĩś ďēvĩćē.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Ŕēśũḿē ţĥē ḿōśţ ŕēćēńţĺŷ ōƥēńēď ţàśķ ĩń ţĥĩś ŵōŕķśƥàćē ōń ţĥĩś ďēvĩćē àţ śţàŕţũƥ. Ĥōḿē śţĩĺĺ ũśēś ţĥē ŕēḿēḿƀēŕēď ĺĩśţĩńĝ.",
|
||||
"retrySave": "Ŕēţŕŷ śàvē",
|
||||
"retryReset": "Ŕēţŕŷ ŕēśēţ",
|
||||
"runningSprites": "Ŕũńńĩńĝ Śƥŕĩţēś",
|
||||
@@ -636,7 +636,8 @@
|
||||
"spriteHealthUnhealthy": "Ũńĥēàĺţĥŷ",
|
||||
"spriteHealthUnknown": "Ũńķńōŵń",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "Śƥŕĩţēś.ďēv ƥŕōvĩďēś ēƥĥēḿēŕàĺ ćĺōũď śàńďƀōxēś ƒōŕ ŕũńńĩńĝ àĝēńţś ŕēḿōţēĺŷ.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Śţàŕţ ōń ŷōũŕ śàvēď Ķàńƀàń, Ƥĩƥēĺĩńē, ōŕ Ĺĩśţ vĩēŵ.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Ōƥēń ţĥē ĺàśţ Ķàńƀàń, Ƥĩƥēĺĩńē, Ĺĩśţ, ōŕ Ţĥŕēàďś vĩēŵ ũśēď ōń ţĥĩś ďēvĩćē.",
|
||||
"openThreadsOnStartupAndHome": "Ōƥēń Ţĥŕēàďś ōń śţàŕţũƥ àńď ŵĥēńēvēŕ ŷōũ ĝō Ĥōḿē ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē, ŕēĝàŕďĺēśś ōƒ ţĥē ĺàśţ ĺĩśţĩńĝ vĩēŵ.",
|
||||
"startupPage": "Śţàŕţũƥ Ƥàĝē",
|
||||
"statusBar": "Śţàţũś Ɓàŕ",
|
||||
"submitShortcut": "Śũƀḿĩţ Śĥōŕţćũţ",
|
||||
@@ -659,7 +660,7 @@
|
||||
"testNotificationBody": "Ĩƒ ŷōũ ćàń ŕēàď ţĥĩś, ƀŕōŵśēŕ ńōţĩƒĩćàţĩōńś àŕē ŵōŕķĩńĝ.",
|
||||
"testNotificationTitle": "Ţēśţ ńōţĩƒĩćàţĩōń",
|
||||
"theBuiltInDefaultLayoutWill": "Ţĥē ƀũĩĺţ-ĩń Ďēƒàũĺţ ĺàŷōũţ ŵĩĺĺ ƀēćōḿē ţĥē ďēƒàũĺţ.",
|
||||
"thisAppliesWhenKandevStarts": "Ţĥĩś àƥƥĺĩēś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ ōƥēń ĩţś ƀàŕē ĥōḿē ƥàĝē. Ĥōḿē, Ɓàćķ, àńď ŵōŕķƒĺōŵ ńàvĩĝàţĩōń àĺŵàŷś ōƥēń ţĥē ţàśķ ōvēŕvĩēŵ.",
|
||||
"thisAppliesWhenKandevStarts": "Ţĥĩś ćĥōĩćē ƒōĺĺōŵś ŷōũŕ ũśēŕ àćŕōśś ďēvĩćēś. Ēxƥĺĩćĩţ ĺĩńķś ķēēƥ ţĥēĩŕ ďēśţĩńàţĩōń, àńď Ōƒƒĩćē ŵōŕķśƥàćēś ķēēƥ ţĥēĩŕ Ōƒƒĩćē Ĥōḿē.",
|
||||
"thisLayoutIsUsedAsThe": "Ţĥĩś ĺàŷōũţ ĩś ũśēď àś ţĥē śţàŕţĩńĝ ĺàŷōũţ ƒōŕ ńēŵ ţàśķś.",
|
||||
"thisPageHasUnsavedChangesSave": "Ţĥĩś ƥàĝē ĥàś ũńśàvēď ćĥàńĝēś. Śàvē ţĥēḿ, ďĩśćàŕď ţĥēḿ, ōŕ ćōńţĩńũē ēďĩţĩńĝ.",
|
||||
"thisProfileWillBeRemovedWhen": "Ţĥĩś ƥŕōƒĩĺē ŵĩĺĺ ƀē ŕēḿōvēď ŵĥēń ŷōũ śàvē.",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"chatInput": "Campo de chat",
|
||||
"chooseAMonospaceFontForThe": "Escolha um tipo de letra monoespaçado para o terminal. As Nerd Fonts incluem ícones para ferramentas de CLI.",
|
||||
"chooseTheLanguageUsedAcrossThe": "Escolha o idioma usado em toda a interface do Kandev",
|
||||
"chooseWhatOpensWhenKandevStarts": "Escolha o que abre quando o Kandev arranca ou quando visita a sua página inicial simples",
|
||||
"chooseWhatOpensWhenKandevStarts": "Escolha um destino de arranque. Selecione Tópicos para o usar também no Início.",
|
||||
"chooseWhichTranscriptNavigationControls": "Escolha que controlos de navegação da transcrição aparecem, incluindo a barra de prompt fixa exclusiva do computador",
|
||||
"clearSettingsSearch": "Limpar a pesquisa nas definições",
|
||||
"clearShortcut": "Limpar o atalho",
|
||||
@@ -211,7 +211,7 @@
|
||||
"discoveryAliasesPreferredShell": "shell,zsh,bash,shell do terminal",
|
||||
"discoveryAliasesResourceMetrics": "métricas,CPU,memória,disco,barra de estado",
|
||||
"discoveryAliasesRichOutputMotion": "animação de gráficos,grafos,desempenho,movimento reduzido",
|
||||
"discoveryAliasesStartupPage": "página inicial,página de arranque,página de lançamento",
|
||||
"discoveryAliasesStartupPage": "página inicial,página de arranque,página de lançamento,tópicos,início predefinido,vista inicial",
|
||||
"discoveryAliasesSubmitShortcut": "tecla de envio,enter para enviar,command enter",
|
||||
"discoveryAliasesTerminalFont": "família de letra,tipo de letra,monoespaçado",
|
||||
"discoveryAliasesTerminalFontSize": "tamanho do texto,texto do terminal",
|
||||
@@ -544,7 +544,7 @@
|
||||
"resourceMetrics": "Métricas de recursos",
|
||||
"richOutputChartMotion": "Movimento dos gráficos apresentados pelo agente",
|
||||
"restoreTheOriginalBuiltInLayout": "Restaurar o esquema incorporado original e descartar a sua substituição.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Retomar a tarefa aberta mais recentemente nesta área de trabalho, neste dispositivo.",
|
||||
"resumeTheMostRecentlyOpenedTask": "Retomar no arranque a tarefa aberta mais recentemente nesta área de trabalho, neste dispositivo. O Início continua a usar a listagem memorizada.",
|
||||
"retrySave": "Repetir a gravação",
|
||||
"retryReset": "Repetir a reposição",
|
||||
"runningSprites": "Sprites em execução",
|
||||
@@ -621,7 +621,8 @@
|
||||
"spriteHealthUnhealthy": "Não saudável",
|
||||
"spriteHealthUnknown": "Desconhecido",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "O Sprites.dev disponibiliza sandboxes efémeras na nuvem para executar agentes remotamente.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Começar na sua vista Kanban, Pipeline ou Lista guardada.",
|
||||
"startOnYourSavedKanbanPipelineOrList": "Abrir a última vista Kanban, Pipeline, Lista ou Tópicos usada neste dispositivo.",
|
||||
"openThreadsOnStartupAndHome": "Abrir Tópicos no arranque e sempre que voltar ao Início na área de trabalho selecionada, independentemente da última vista de listagem.",
|
||||
"startupPage": "Página de arranque",
|
||||
"submitShortcut": "Atalho de envio",
|
||||
"supportedEditors": "Editores suportados",
|
||||
@@ -639,7 +640,7 @@
|
||||
"testNotificationBody": "Se consegue ler isto, as notificações do navegador estão a funcionar.",
|
||||
"testNotificationTitle": "Notificação de teste",
|
||||
"theBuiltInDefaultLayoutWill": "O esquema Pré-definido incorporado passa a ser a pré-definição.",
|
||||
"thisAppliesWhenKandevStarts": "Isto aplica-se quando o Kandev arranca ou quando abre a sua página inicial simples. O Início, o Voltar e a navegação pelos fluxos de trabalho abrem sempre a visão geral da tarefa.",
|
||||
"thisAppliesWhenKandevStarts": "Esta escolha acompanha o seu utilizador em todos os dispositivos. As ligações explícitas mantêm o destino e as áreas de trabalho Office mantêm o Início do Office.",
|
||||
"thisLayoutIsUsedAsThe": "Este esquema é usado como esquema inicial das novas tarefas.",
|
||||
"thisPageHasUnsavedChangesSave": "Esta página tem alterações por guardar. Guarde-as, descarte-as ou continue a editar.",
|
||||
"thisProfileWillBeRemovedWhen": "Este perfil será removido quando guardar.",
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"chooseAMonospaceFontForThe": "为终端选择等宽字体。Nerd Fonts 包含 CLI 工具所需的图标。",
|
||||
"chooseHowDeepTheSettingsMenuGoes": "选择设置菜单仅列出页面,还是将工作区、智能体和执行器展开为其中的具体内容。",
|
||||
"chooseTheLanguageUsedAcrossThe": "选择 Kandev 界面使用的语言",
|
||||
"chooseWhatOpensWhenKandevStarts": "选择 Kandev 启动时或访问其不带路径的主页时打开的内容",
|
||||
"chooseWhatOpensWhenKandevStarts": "选择启动时打开的页面。选择对话流后,主页也将打开对话流。",
|
||||
"chooseWhichTranscriptNavigationControls": "选择要显示的对话记录导航控件,包括仅限桌面的固定提示栏",
|
||||
"clearSettingsSearch": "清除设置搜索",
|
||||
"clearShortcut": "清除快捷键",
|
||||
@@ -215,7 +215,7 @@
|
||||
"discoveryAliasesResourceMetrics": "指标,CPU,内存,磁盘,状态栏",
|
||||
"discoveryAliasesRichOutputMotion": "图表动画,图形,性能,减少动态效果",
|
||||
"discoveryAliasesSettingsMenu": "侧边栏,导航,树形菜单,单分支展开,保留展开,菜单",
|
||||
"discoveryAliasesStartupPage": "主页,起始页,启动页",
|
||||
"discoveryAliasesStartupPage": "主页,起始页,启动页,对话流,默认主页,主页视图",
|
||||
"discoveryAliasesStatusBar": "底栏,状态面板,连接状态,外观",
|
||||
"discoveryAliasesSubmitShortcut": "发送键,回车发送,Command Enter",
|
||||
"discoveryAliasesTerminalFont": "字体系列,字体,等宽",
|
||||
@@ -558,7 +558,7 @@
|
||||
"resourceMetrics": "资源指标",
|
||||
"richOutputChartMotion": "智能体图表动态效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢复原始内置布局并舍弃其覆盖设置。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此设备上恢复此工作区中最近打开的任务。",
|
||||
"resumeTheMostRecentlyOpenedTask": "启动时恢复此设备上当前工作区中最近打开的任务。主页仍使用记住的任务列表视图。",
|
||||
"retrySave": "重试保存",
|
||||
"retryReset": "重试重置",
|
||||
"runningSprites": "运行中的云沙盒",
|
||||
@@ -638,7 +638,8 @@
|
||||
"spriteHealthUnhealthy": "异常",
|
||||
"spriteHealthUnknown": "未知",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev 提供临时云沙盒,用于远程运行智能体。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "从保存的看板、流水线或列表视图开始。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "打开此设备上最后使用的看板、流水线、列表或对话流视图。",
|
||||
"openThreadsOnStartupAndHome": "启动时或返回主页时,在选定工作区中打开对话流,不受最后使用的任务列表视图影响。",
|
||||
"startupPage": "启动页面",
|
||||
"statusBar": "状态栏",
|
||||
"submitShortcut": "提交快捷键",
|
||||
@@ -661,7 +662,7 @@
|
||||
"testNotificationBody": "如果你能读到这条消息,说明浏览器通知正常工作。",
|
||||
"testNotificationTitle": "测试通知",
|
||||
"theBuiltInDefaultLayoutWill": "内置“默认”布局将成为默认布局。",
|
||||
"thisAppliesWhenKandevStarts": "这适用于 Kandev 启动或打开不带路径的主页时。主页、返回和工作流导航始终打开任务概览。",
|
||||
"thisAppliesWhenKandevStarts": "此选择随用户在各设备间同步。明确指定的链接保留原有目标,Office 工作区仍使用 Office 主页。",
|
||||
"thisLayoutIsUsedAsThe": "此布局将用作新任务的起始布局。",
|
||||
"thisPageHasUnsavedChangesSave": "此页面有未保存的更改。请保存、放弃或继续编辑。",
|
||||
"thisProfileWillBeRemovedWhen": "保存时将移除此配置。",
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"chooseAMonospaceFontForThe": "為終端選擇等寬字體。Nerd Fonts 包含 CLI 工具所需的圖標。",
|
||||
"chooseHowDeepTheSettingsMenuGoes": "選擇設定菜單僅列出頁面,還是將工作區、代理程式和執行器展開為其中的具體內容。",
|
||||
"chooseTheLanguageUsedAcrossThe": "選擇 Kandev 介面使用的語言",
|
||||
"chooseWhatOpensWhenKandevStarts": "選擇 Kandev 啓動時或存取其不帶路徑的主頁時開啟的內容",
|
||||
"chooseWhatOpensWhenKandevStarts": "選擇啓動時開啟的頁面。選擇對話流後,主頁也將開啟對話流。",
|
||||
"chooseWhichTranscriptNavigationControls": "選擇要顯示的對話記錄導航控制項,包括僅限桌面的固定提示欄",
|
||||
"clearSettingsSearch": "清除設定搜尋",
|
||||
"clearShortcut": "清除快捷鍵",
|
||||
@@ -215,7 +215,7 @@
|
||||
"discoveryAliasesResourceMetrics": "指標,CPU,記憶體,磁碟,狀態欄",
|
||||
"discoveryAliasesRichOutputMotion": "圖表動畫,圖形,性能,減少動態效果",
|
||||
"discoveryAliasesSettingsMenu": "側邊欄,導航,樹形菜單,單分支展開,保留展開,菜單",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啓動頁",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啓動頁,對話流,預設主頁,主頁視圖",
|
||||
"discoveryAliasesStatusBar": "底欄,狀態面板,連接狀態,外觀",
|
||||
"discoveryAliasesSubmitShortcut": "傳送鍵,回車傳送,Command Enter",
|
||||
"discoveryAliasesTerminalFont": "字體系列,字體,等寬",
|
||||
@@ -558,7 +558,7 @@
|
||||
"resourceMetrics": "資源指標",
|
||||
"richOutputChartMotion": "代理程式圖表動態效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢復原始內置佈局並捨棄其覆蓋設定。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此設備上恢復此工作區中最近開啟的任務。",
|
||||
"resumeTheMostRecentlyOpenedTask": "啓動時恢復此設備上目前工作區中最近開啟的任務。主頁仍使用記住的任務列表視圖。",
|
||||
"retrySave": "重試儲存",
|
||||
"retryReset": "重試重置",
|
||||
"runningSprites": "執行中的雲沙盒",
|
||||
@@ -638,7 +638,8 @@
|
||||
"spriteHealthUnhealthy": "異常",
|
||||
"spriteHealthUnknown": "未知",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev 提供臨時雲沙盒,用於遠端執行代理程式。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "從儲存的看板、流水線或列表視圖開始。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "開啟此設備上最後使用的看板、流水線、列表或對話流視圖。",
|
||||
"openThreadsOnStartupAndHome": "啓動時或返回主頁時,在選定工作區中開啟對話流,不受最後使用的任務列表視圖影響。",
|
||||
"startupPage": "啓動頁面",
|
||||
"statusBar": "狀態欄",
|
||||
"submitShortcut": "提交快捷鍵",
|
||||
@@ -661,7 +662,7 @@
|
||||
"testNotificationBody": "如果你能讀到這條訊息,說明瀏覽器通知正常工作。",
|
||||
"testNotificationTitle": "測試通知",
|
||||
"theBuiltInDefaultLayoutWill": "內置「預設」佈局將成為預設佈局。",
|
||||
"thisAppliesWhenKandevStarts": "這適用於 Kandev 啓動或開啟不帶路徑的主頁時。主頁、返回和工作流程導航始終開啟任務概覽。",
|
||||
"thisAppliesWhenKandevStarts": "此選擇隨用戶在各設備間同步。明確指定的連結保留原有目標,Office 工作區仍使用 Office 主頁。",
|
||||
"thisLayoutIsUsedAsThe": "此佈局將用作新任務的起始佈局。",
|
||||
"thisPageHasUnsavedChangesSave": "此頁面有未儲存的更改。請儲存、放棄或繼續編輯。",
|
||||
"thisProfileWillBeRemovedWhen": "儲存時將移除此設定。",
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"chooseAMonospaceFontForThe": "為終端選擇等寬字型。Nerd Fonts 包含 CLI 工具所需的圖示。",
|
||||
"chooseHowDeepTheSettingsMenuGoes": "選擇設定選單僅列出頁面,還是將工作區、代理程式和執行器展開為其中的具體內容。",
|
||||
"chooseTheLanguageUsedAcrossThe": "選擇 Kandev 介面使用的語言",
|
||||
"chooseWhatOpensWhenKandevStarts": "選擇 Kandev 啟動時或存取其不帶路徑的主頁時開啟的內容",
|
||||
"chooseWhatOpensWhenKandevStarts": "選擇啟動時開啟的頁面。選擇對話流後,主頁也將開啟對話流。",
|
||||
"chooseWhichTranscriptNavigationControls": "選擇要顯示的對話記錄導覽控制項,包括僅限桌面的固定提示欄",
|
||||
"clearSettingsSearch": "清除設定搜尋",
|
||||
"clearShortcut": "清除快捷鍵",
|
||||
@@ -215,7 +215,7 @@
|
||||
"discoveryAliasesResourceMetrics": "指標,CPU,記憶體,磁碟,狀態列",
|
||||
"discoveryAliasesRichOutputMotion": "圖表動畫,圖形,效能,減少動態效果",
|
||||
"discoveryAliasesSettingsMenu": "側邊欄,導覽,樹形選單,單分支展開,保留展開,選單",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啟動頁",
|
||||
"discoveryAliasesStartupPage": "主頁,起始頁,啟動頁,對話流,預設主頁,主頁檢視",
|
||||
"discoveryAliasesStatusBar": "底欄,狀態面板,連線狀態,外觀",
|
||||
"discoveryAliasesSubmitShortcut": "傳送鍵,回車傳送,Command Enter",
|
||||
"discoveryAliasesTerminalFont": "字體系列,字型,等寬",
|
||||
@@ -558,7 +558,7 @@
|
||||
"resourceMetrics": "資源指標",
|
||||
"richOutputChartMotion": "代理程式圖表動態效果",
|
||||
"restoreTheOriginalBuiltInLayout": "恢復原始內建佈局並捨棄其覆蓋設定。",
|
||||
"resumeTheMostRecentlyOpenedTask": "在此裝置上恢復此工作區中最近開啟的任務。",
|
||||
"resumeTheMostRecentlyOpenedTask": "啟動時恢復此裝置上目前工作區中最近開啟的任務。主頁仍使用記住的任務清單檢視。",
|
||||
"retrySave": "重試儲存",
|
||||
"retryReset": "重試重置",
|
||||
"runningSprites": "執行中的雲沙盒",
|
||||
@@ -638,7 +638,8 @@
|
||||
"spriteHealthUnhealthy": "異常",
|
||||
"spriteHealthUnknown": "未知",
|
||||
"spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev 提供臨時雲沙盒,用於遠端執行代理程式。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "從儲存的看板、流水線或清單檢視開始。",
|
||||
"startOnYourSavedKanbanPipelineOrList": "開啟此裝置上最後使用的看板、流水線、清單或對話流檢視。",
|
||||
"openThreadsOnStartupAndHome": "啟動時或返回主頁時,在選定工作區中開啟對話流,不受最後使用的任務清單檢視影響。",
|
||||
"startupPage": "啟動頁面",
|
||||
"statusBar": "狀態列",
|
||||
"submitShortcut": "提交快捷鍵",
|
||||
@@ -661,7 +662,7 @@
|
||||
"testNotificationBody": "如果你能讀到這條訊息,說明瀏覽器通知正常工作。",
|
||||
"testNotificationTitle": "測試通知",
|
||||
"theBuiltInDefaultLayoutWill": "內建「預設」佈局將成為預設佈局。",
|
||||
"thisAppliesWhenKandevStarts": "這適用於 Kandev 啟動或開啟不帶路徑的主頁時。主頁、返回和工作流程導覽始終開啟任務概覽。",
|
||||
"thisAppliesWhenKandevStarts": "此選擇隨使用者在各裝置間同步。明確指定的連結保留原有目標,Office 工作區仍使用 Office 主頁。",
|
||||
"thisLayoutIsUsedAsThe": "此佈局將用作新任務的起始佈局。",
|
||||
"thisPageHasUnsavedChangesSave": "此頁面有未儲存的更改。請儲存、放棄或繼續編輯。",
|
||||
"thisProfileWillBeRemovedWhen": "儲存時將移除此設定。",
|
||||
|
||||
@@ -308,7 +308,7 @@ export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) {
|
||||
return <PluginRoute path={route.path} />;
|
||||
}
|
||||
if (route.kind === "kanban") {
|
||||
return <KanbanRoute route={route} fallback={<RouteLoading routeNameKey="sidebar:office" />} />;
|
||||
return <KanbanRoute route={route} fallback={<RouteLoading routeNameKey="sidebar:home" />} />;
|
||||
}
|
||||
if (route.kind === "threads") {
|
||||
return (
|
||||
|
||||
@@ -54,7 +54,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SpaRoutes data-backed workspace context", () => {
|
||||
it("passes the selected workspace to the kanban home client", () => {
|
||||
it("passes the selected workspace to the kanban home client after bootstrap", async () => {
|
||||
mockGitHubWorkspaceBootstrap();
|
||||
window.history.replaceState({}, "", `/?workspaceId=${SELECTED_WORKSPACE_ID}`);
|
||||
|
||||
@@ -64,9 +64,11 @@ describe("SpaRoutes data-backed workspace context", () => {
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("kanban-page").getAttribute("data-workspace-id")).toBe(
|
||||
SELECTED_WORKSPACE_ID,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("kanban-page").getAttribute("data-workspace-id")).toBe(
|
||||
SELECTED_WORKSPACE_ID,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the currently active workspace when opening GitHub from another workspace", async () => {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
created: 2026-09-10
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-listing-display-preferences.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Threads Home Default
|
||||
|
||||
## Overview
|
||||
|
||||
Add Threads to the existing portable Startup Page choice, honor it on bare
|
||||
startup and Home navigation, and keep device-local listing memory independent.
|
||||
Implement the settings contract first, then routing, then the localized choice
|
||||
and desktop/mobile browser proof. All work orders are sequential.
|
||||
|
||||
This task's workspace is
|
||||
`/home/zeval/.kandev/tasks/choose-threads-as-de_94whggoq/kandev-2`.
|
||||
The initial worktree was clean. No reviewed package for a fixed Threads Home
|
||||
default was present; the prior
|
||||
[task-listing plan](../task-listing-display-preferences/plan.md) is completed
|
||||
historical work. This package was handed off before implementation, which the
|
||||
user authorized with the subsequent "go for impl" request.
|
||||
|
||||
## Sources and reconciliation
|
||||
|
||||
- [Owning requirements](../../specs/ui/requirements/task-listing-display-preferences.md)
|
||||
retain `REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001`, extract the already
|
||||
shipped startup behavior into `002`, and define the extension in `003`.
|
||||
UI owns the reusable presentation preference, not task or workspace state.
|
||||
- [System design](../../specs/ui/system-design/task-listing-display-preferences.md)
|
||||
moves technical material out of the old requirement's migrated prose and
|
||||
defines precedence without duplicating the Threads deck contract.
|
||||
- `view-preference.ts` and its tests already accept Threads. The requirement
|
||||
and public task-listing documentation still listed only three modes; that
|
||||
mismatch is reconciled in this package, not treated as missing deck support.
|
||||
- At reconciliation, `StartupPage`, `NormalizeStartupPage`, `applyStartupPage`,
|
||||
the settings radio card, and frontend parsing accepted only overview/last task.
|
||||
- At reconciliation, `workspaceHomeHref` and `homeDestinationHref` duplicated
|
||||
Home policy; `nav-home` additionally used a workspace-less overview override.
|
||||
- [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md),
|
||||
[ADR 0023](../../decisions/0023-active-workspace-cookie.md), and
|
||||
[Office mode](../../decisions/2026-08-15-office-mode-follows-active-workspace.md)
|
||||
retain settings and workspace authority. No new ADR is needed: this is a
|
||||
bounded extension whose rationale is preserved in the owning design.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Portable `startup_page: "threads"`, preserving the existing default and
|
||||
patch/revision semantics across storage, HTTP, WS, and boot hydration.
|
||||
- Fixed Threads Home overriding remembered mode only for generic Home and bare
|
||||
startup. An explicit route remains explicit.
|
||||
- Desktop/sidebar, phone/menu, settings-exit, workspace-picker, and Home-command
|
||||
wiring through the shared destination policy.
|
||||
- Startup settings copy, discovery aliases, all locales, and browser evidence.
|
||||
- Public task-listing documentation when implementation ships.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Parent dirty worktree, its demo data, swipe-indicator timing, and mobile
|
||||
topbar normalization. A phone Home href change is wiring only.
|
||||
- New Kandev tasks/sessions, agent launch, subagent delegation, commit, push,
|
||||
PR, or a new worktree as an execution mechanism.
|
||||
- New per-device or per-workspace Home stores, schema migrations, feature
|
||||
flags, or fixed defaults for every other listing mode.
|
||||
- Changing task Back/Task overview links, task lifecycle, Threads saved-view
|
||||
selection, deck filtering, or resource budgets.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Portable startup choice
|
||||
|
||||
Extend `internal/user/models` and `internal/user/service` to retain/validate
|
||||
`threads`. Verify the existing DTO, JSON settings store, revision-guarded
|
||||
service, event, and `mapUserSettingsState` boot paths. Extend frontend
|
||||
`StartupPage` and the common settings mapper; the same field already flows
|
||||
through Appearance draft/save and the settings store. Do not add a column or
|
||||
new default field. See [Task 01](task-01-persist-threads-startup-choice.md).
|
||||
|
||||
### 2. Home intent and entry routing
|
||||
|
||||
Centralize Home href policy in `lib/navigation/workspace-home.ts`; adapt the
|
||||
manifest and direct callers. Carry the saved startup choice through
|
||||
`NavContext`/`useNavContext`. Home in a non-Office workspace resolves directly
|
||||
to Threads when selected. Preserve existing overview links for existing
|
||||
choices; `nav-home`'s old overview override is bypassed only for the new choice.
|
||||
|
||||
Bare root resolution belongs to `startup-page.ts` and `PageClient`, using
|
||||
existing explicit-destination guards and one redirect per resolution. Expose
|
||||
workspace-scoped bootstrap readiness in `useKanbanRouteBootstrap` so settings
|
||||
and workspace data settle before the default applies. Fix the local listing
|
||||
hook's synthetic `loaded: true` write to preserve real readiness. Empty/error
|
||||
bootstrap paths must settle without requiring board snapshots or workflows.
|
||||
See [Task 02](task-02-resolve-threads-home-navigation.md).
|
||||
|
||||
### 3. Selectable settings and integrated proof
|
||||
|
||||
Add the third radio to `StartupPageSettingsCard`; reuse the existing Appearance
|
||||
contributor and shared Save/discard/error flow. Revise descriptions and search
|
||||
aliases in all locales to distinguish last-used overview, startup-only last
|
||||
task, and fixed Threads Home. Existing native settings rows and Threads deck
|
||||
provide mobile composition. See
|
||||
[Task 03](task-03-expose-threads-home-choice.md).
|
||||
|
||||
Update the how-to section **Find and organize tasks** in
|
||||
`docs/public/tasks-and-workflows.md` with supported device modes, the three
|
||||
startup choices, and Home versus explicit-route behavior. Publication belongs
|
||||
to implementation, not the preceding design-only turn.
|
||||
|
||||
## Tests
|
||||
|
||||
The following additions and extensions implement the acceptance coverage.
|
||||
Each work order records its RED/GREEN results.
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| `001.3` to `001.6`, `001.12` | `view-preference.test.ts` and `use-task-listing-view.test.tsx`: Threads, invalid/missing/legacy values, blocked writes, phone Pipeline fallback, startup choice unchanged by view changes |
|
||||
| `002.1`, `002.5`, `003.3` | Backend `TestApplyStartupPage`, `TestScanUserSettingsStartupPage`, DTO/event/boot startup tests; new repository startup persistence tests for SQLite/PostgreSQL and separate users; frontend mapper/WS startup cases |
|
||||
| `002.2` to `002.4` | `startup-page.test.ts`, `page-client.test.tsx`: workspace-local recent task, no matching task, explicit overview/task/workflow guards, no recent-task resume for Threads |
|
||||
| `003.4`, `003.6` | New `lib/navigation/workspace-home.test.ts` Home matrix; existing navigation resolver, sidebar workspace-navigation, Home affordance, and app-destinations hook tests |
|
||||
| `003.4`, `003.5`, `003.7` | `page-client.test.tsx`, new `src/kanban-route-startup.test.tsx`, and `view-navigation.test.ts`: competing remembered value, deferred bootstrap, stale workspace completion, explicit routes, empty workspace/deck, failed bootstrap recovery |
|
||||
| `003.1`, `003.2` | New `appearance-settings-state.test.ts` startup draft/patch/rebase cases; existing general-settings/save-provider regressions and browser radio/save/discard/error flow |
|
||||
| `003.8` | Phone E2E: labelled radio row at least 44px high, tap Save, Home to native deck, explicit route reload, no horizontal overflow |
|
||||
|
||||
The unchanged workflow-filter and rich-row criteria (`001.1`, `001.2`,
|
||||
`001.7` to `001.11`) retain the completed prior plan's evidence. Existing
|
||||
task-listing desktop/mobile specs run as regression neighbours because this
|
||||
change touches their shared view navigation.
|
||||
|
||||
## E2E tests
|
||||
|
||||
Use isolated worker fixtures only. Capture settings before patches and restore
|
||||
them in `afterEach`, acquiring `testPage` before changing persistent defaults.
|
||||
Use causal save-response waits and final UI assertions; no fixed sleeps or
|
||||
larger locator timeouts. Rebuild through the managed runner. Confirm each
|
||||
project discovers its intended tests; desktop and mobile run separately.
|
||||
|
||||
Extend `e2e/tests/settings/startup-page.spec.ts` (`chromium`):
|
||||
|
||||
- **saves Threads as the Home default independently of the last listing**:
|
||||
choose and save Threads, reload Settings, use List, assert `/tasks` survives
|
||||
reload, then use Home and reload bare root to reach Threads in the same
|
||||
workspace. Clearing only the listing key cannot disable fixed Threads.
|
||||
Covers `003.1` to `003.5` and `003.7`.
|
||||
- **keeps Threads draft changes pending until saved**: discard the selection;
|
||||
then fail one settings save, retain the prior backend choice and retryable
|
||||
draft, and retry successfully. Covers `003.2`.
|
||||
- **keeps every Home entry in the selected workspace**: brand/Home row,
|
||||
settings exit, Home command, and workspace picker target the selected
|
||||
workspace. Include two non-Office workspaces with distinct task titles, then
|
||||
test the empty Threads state. Covers `003.4`, `003.6`, `003.7`.
|
||||
- **preserves explicit destinations with a Threads Home default**: select
|
||||
Kanban/Pipeline/List, reload, use a workflow URL and explicit task/session
|
||||
route, and follow a focused Threads URL. Verify path, workspace, and focus
|
||||
remain intact; browser Back/Forward does not force Home. Covers `003.5`.
|
||||
- Retain both existing last-task tests for `002.1` to `002.4`. Add a real
|
||||
second browser context or fresh browser session using the same user backend
|
||||
with no listing key to prove `003.3`; do not run the test fixture's settings
|
||||
reset in that second context.
|
||||
|
||||
Extend `e2e/tests/settings/mobile-startup-page.spec.ts` (`mobile-chrome`):
|
||||
|
||||
- **saves Threads from phone settings and returns Home after using List**:
|
||||
enter Settings through phone navigation, tap the labelled Threads row, Save,
|
||||
reload, switch to List through the drawer, and tap the mobile menu Home row.
|
||||
Verify workspace, native deck, and a subsequent reload. Covers `003.1` to
|
||||
`003.4`, `003.6`, `003.8`.
|
||||
- **keeps explicit phone destinations with Threads selected**: List reload,
|
||||
task/Task overview links, and focused Threads link remain explicit; missing
|
||||
local memory still permits Threads on Home. Assert row hit area, Save
|
||||
visibility, safe-area clearance, and no horizontal overflow. Covers `003.5`,
|
||||
`003.7`, `003.8`; retain the existing last-task scenario.
|
||||
|
||||
Extend the Home-focused cases in `e2e/tests/office/sidebar-navigation.spec.ts`
|
||||
and `mobile-office-navigation.spec.ts` using `startup_page: "threads"` to
|
||||
prove Office Home still works from shared Settings surfaces (`003.6`). These
|
||||
are normal `chromium` and `mobile-chrome` navigation tests, not the provider
|
||||
`routing` project. Do not exercise provider-routing scenarios for this change.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Persist the Threads startup choice](task-01-persist-threads-startup-choice.md) (wave 1, done)
|
||||
- [x] [Task 02: Resolve Threads Home navigation](task-02-resolve-threads-home-navigation.md) (wave 2, depends on 01, done)
|
||||
- [x] [Task 03: Expose the Threads Home choice](task-03-expose-threads-home-choice.md) (wave 3, depends on 02, done)
|
||||
|
||||
## Verification results
|
||||
|
||||
PR fixup scope also includes preserving disabled-Office phone Home routing,
|
||||
repairing the workspace-picker test fixture, direct fallback resolver coverage,
|
||||
and aligning specification index labels and backend verification build tags.
|
||||
Task 02 records the remediation sequence and completed local validation.
|
||||
The later full frontend CI run exposed three more incomplete navigation test
|
||||
fixtures. Their test-only repair and expanded desktop/phone Home assertions
|
||||
are also recorded in Task 02; no additional product contract change is needed.
|
||||
The current-main merge retained both UI design index entries and passed the
|
||||
122-test navigation suite, full web lint/typecheck/i18n, tagged startup/boot
|
||||
tests, and 21 focused desktop/phone browser scenarios. Broad frontend and
|
||||
current-head remote CI/review results are tracked in the PR delivery evidence.
|
||||
Later E2E CI exposed a startup-readiness regression when switching to All
|
||||
Workflows. Task 02 owns recording the hydrated fast-path completion, regression
|
||||
coverage for live filters and route changes, and focused browser verification.
|
||||
The existing listing-availability contract is clarified before this repair.
|
||||
The landed parent polish moved phone Home into the shared menu. Preserve its
|
||||
header and swipe implementation unchanged, retain the Threads choice through
|
||||
the shared navigation context, and update this package's phone entry-point
|
||||
tests and current design. The mobile parked-session flake is a separate
|
||||
test-profile cleanup defect, with no product contract change.
|
||||
After this integration, 109 focused frontend tests and 73 desktop/phone E2E
|
||||
tests passed, including every failed CI filter case and the profile cleanup
|
||||
regression. Full web lint/typecheck/i18n and specification/harness/public-docs
|
||||
validators passed. Tasks 02 and 03 record exact commands; current-head remote
|
||||
CI and review remain pending until the remediation push is verified.
|
||||
|
||||
- `python3 scripts/lint-spec-files.test.py`: passed, 30 tests.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed for all specification files.
|
||||
- `git diff --check -- docs/specs docs/plans`: passed.
|
||||
- Task 01: startup enum/persistence, boot mapping, SQL guard, and SQLite and
|
||||
PostgreSQL fresh/replay/upgrade conformance passed. Frontend mapping and WS:
|
||||
87 tests passed.
|
||||
- Task 02: 148 routing/navigation tests passed, including delayed and cancelled
|
||||
bootstrap, Office, empty/error fallback, and explicit destinations.
|
||||
- Implementation-checkpoint frontend run: 272 tests passed across 21 files, including
|
||||
blocked/malformed local storage and settings draft/rebase/save regressions.
|
||||
- Task 03: 19 browser tests passed with strict WS checks and no retries:
|
||||
desktop startup/listing 8, phone startup/listing 5, desktop Office Home 3,
|
||||
and phone Office navigation 3. Native phone screenshots were inspected;
|
||||
temporary artifact paths are recorded in Task 03.
|
||||
- Typecheck, focused ESLint, all locale checks, and the production build passed.
|
||||
- Public docs validation passed for 46 pages and its validator test passed.
|
||||
- PR review remediation: the E2E settings response now declares the optional
|
||||
workspace and workflow IDs used by cleanup. A targeted compiler check
|
||||
reproduced both unknown-to-string errors before the fix and passed afterward;
|
||||
two API-helper tests and focused ESLint passed. The helper change is test-only.
|
||||
- PR routing remediation: 88 focused frontend tests and seven phone E2E tests
|
||||
passed against a rebuilt web bundle, with retries disabled. The new
|
||||
disabled-Office regression failed on the old href before the fix. Typecheck,
|
||||
focused ESLint, specification and public-docs validation passed. Backend
|
||||
startup, boot mapping, and SQLite conformance passed with `-tags fts5 -race`;
|
||||
PostgreSQL was not rerun for this frontend/documentation-only remediation.
|
||||
- Requirements are active, system design is current, and all three work orders
|
||||
are done. The task-owned disposable PostgreSQL fixture was removed after
|
||||
successful persistence and upgrade checks.
|
||||
|
||||
Each work order records its exact implementation commands and results;
|
||||
execution was authorized by the user's subsequent "go for impl" request.
|
||||
Public documentation changes were completed in Task 03. At the implementation
|
||||
checkpoint, no commit, push, PR, agent delegation, or parent-worktree edit had
|
||||
been performed. The user subsequently authorized PR publication and fixup.
|
||||
|
||||
## Risks
|
||||
|
||||
- A single forgotten Home consumer or the palette override would make the
|
||||
preference appear inconsistent. Integration coverage must click those
|
||||
actual entry points, not only test the resolver.
|
||||
- `home=overview` already means remembered overview and suppresses recent-task
|
||||
resume. Treating it as generic Home would break explicit view selection.
|
||||
- Deferred settings hydration could navigate to remembered List before the
|
||||
portable Threads choice arrives. Empty workspaces must not deadlock the
|
||||
readiness gate, and Office must resolve before task-listing defaults.
|
||||
- This is a per-user choice applied to the current workspace, not a new
|
||||
per-workspace default. Settings copy must make the distinction from local
|
||||
recent-task and listing memory clear.
|
||||
- Old binaries normalize unknown startup values to overview; normal release
|
||||
upgrade preserves existing values, but downgrades do not understand Threads.
|
||||
- PostgreSQL coverage requires a disposable test database via
|
||||
`KANDEV_TEST_POSTGRES_DSN`; record a skip as missing evidence, not success.
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
id: "01-persist-threads-startup-choice"
|
||||
title: "Persist the Threads startup choice"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.1
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.5
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-listing-display-preferences.md
|
||||
---
|
||||
|
||||
# Task 01: Persist the Threads Startup Choice
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing `startup_page` setting with `threads` and prove that all
|
||||
storage and delivery paths retain it. The existing default, user ownership,
|
||||
revision ordering, and partial-update semantics remain authoritative.
|
||||
|
||||
## In scope
|
||||
|
||||
- Use `/tdd`; start with failing enum, persistence, and mapper cases.
|
||||
- Add the backend constant and validation/normalization case. Exercise
|
||||
controller/DTO, JSON scan/marshal, update event, and Go boot payload.
|
||||
- Add focused repository tests that save/reopen Threads, preserve it through
|
||||
an unrelated patch, keep users independent, and retain old/unknown-value
|
||||
behavior. Test SQLite and environment-gated PostgreSQL via the existing
|
||||
isolated-database helper.
|
||||
- Extend the frontend `StartupPage` union, common parser, HTTP/save mapping,
|
||||
and WS tests. Update E2E API helper type declarations to accept Threads.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Home routing, settings markup/copy, new fields, SQL schema changes, and
|
||||
localStorage writes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `threads` survives save, repository reopen, HTTP DTO, WS event, Go boot
|
||||
mapping, and frontend mapping; existing values still round-trip.
|
||||
- Unsupported submitted values fail without mutation; absent/unknown stored
|
||||
values retain overview fallback and omitted patches preserve saved values.
|
||||
- The field stays per-user in existing JSON settings and does not affect
|
||||
listing/recent-task storage, workspace selection, or unrelated settings.
|
||||
|
||||
## Verification
|
||||
|
||||
Install dependencies once in this worktree before the first pnpm command:
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
```
|
||||
|
||||
Run from the repository root; each directory change is scoped to one command:
|
||||
|
||||
```bash
|
||||
(cd apps/backend && go test -tags fts5 -race ./internal/user/... -run StartupPage -count=1)
|
||||
(cd apps/backend && go test -tags fts5 -race ./internal/backendapp -run TestMapUserSettingsStateIncludesNormalizedStartupPage -count=1)
|
||||
make -C apps/backend sqlguard
|
||||
(cd apps/backend && go test -tags fts5 -race ./internal/persistence/storeconformance -count=1)
|
||||
(cd apps && pnpm --filter @kandev/web test lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Configure `KANDEV_TEST_POSTGRES_DSN` to a disposable test PostgreSQL database
|
||||
before the Go checks so repository and conformance PostgreSQL subtests run.
|
||||
The conformance command includes fresh, replay, and previous-Stable upgrade
|
||||
coverage. No fixture/manifest edits are needed without a schema-history change.
|
||||
If PostgreSQL cannot run, record its exact missing evidence. New Go test names
|
||||
must contain `StartupPage` so the focused command discovers them.
|
||||
|
||||
PR review aligned these commands with CI's `fts5` build tag and made the
|
||||
blocks safe to paste from the repository root. The tagged startup-page, boot
|
||||
mapping, and SQLite fresh/replay/upgrade conformance commands passed during
|
||||
fixup. PostgreSQL was not rerun: the original disposable fixture had been
|
||||
removed after the successful implementation checks, and remediation changed
|
||||
no backend implementation or persistence contract.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/user/models/models.go`
|
||||
- `apps/backend/internal/user/models/startup_page_test.go` (new)
|
||||
- `apps/backend/internal/user/service/service.go` and existing startup tests in
|
||||
`service_test.go`; put new event cases in `startup_page_test.go` if needed
|
||||
- `apps/backend/internal/user/dto/dto_test.go`
|
||||
- `apps/backend/internal/user/controller/controller_test.go`
|
||||
- `apps/backend/internal/user/store/sqlite_test.go` (existing cases) and
|
||||
`startup_page_test.go` (new repository tests)
|
||||
- `apps/backend/internal/backendapp/boot_state_user_settings_test.go`
|
||||
- `apps/web/lib/types/http-user-settings.ts`
|
||||
- `apps/web/lib/ssr/user-settings.ts` and `user-settings.test.ts`
|
||||
- `apps/web/lib/ws/handlers/users.test.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts`
|
||||
|
||||
Existing DTO/store/event/boot production code already calls the normalizer.
|
||||
Change it only if a focused regression proves an additional propagation gap.
|
||||
Do not grow already oversized Go test files with new helper suites.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- A forgotten normalizer would coerce a successfully saved Threads value back
|
||||
to overview during hydration.
|
||||
- A settings patch must retain the existing revision and user isolation rules.
|
||||
- PostgreSQL tests silently skip without their DSN; inspect subtest results.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Owning requirements `002.1`, `002.5`, and `003.3`.
|
||||
- Design sections **Preference ownership** and **Portable settings contract**.
|
||||
- `TestApplyStartupPage`, `TestScanUserSettingsStartupPage`, DTO startup patch
|
||||
tests, and `TestMapUserSettingsStateIncludesNormalizedStartupPage`.
|
||||
- `apps/backend/AGENTS.md`, `apps/web/AGENTS.md`, and ADR 0041.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented after frontend and backend assertion failures confirmed the missing
|
||||
enum support. Startup settings tests pass with SQLite and isolated PostgreSQL;
|
||||
boot mapping, SQL guard, 87 frontend mapper/WS tests, and typecheck pass.
|
||||
Fresh/replay and previous-Stable storage conformance passed on SQLite and
|
||||
PostgreSQL (128s), using localhost access after the sandbox blocked its initial
|
||||
PostgreSQL connections. No schema changes were needed.
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
id: "02-resolve-threads-home-navigation"
|
||||
title: "Resolve Threads Home navigation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-persist-threads-startup-choice"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.3
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.4
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.5
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.6
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.12
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.2
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.3
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.4
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.6
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.7
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-listing-display-preferences.md
|
||||
---
|
||||
|
||||
# Task 02: Resolve Threads Home Navigation
|
||||
|
||||
## Summary
|
||||
|
||||
Make generic Home and bare startup honor the portable Threads choice in the
|
||||
resolved workspace. Keep explicit navigation and remembered listing behavior
|
||||
independent, including when settings bootstrap is delayed.
|
||||
|
||||
## In scope
|
||||
|
||||
- Use `/tdd`; add the design's entry-resolution and Home href matrices first.
|
||||
- Share the Home resolver between `workspaceHomeHref`, `homeDestinationHref`,
|
||||
and their actual sidebar/topbar/mobile/settings/picker/command consumers.
|
||||
Carry `startupPage` in `NavContext` and keep old callers' default behavior.
|
||||
- Override the palette's legacy overview href for the Threads choice only.
|
||||
- Resolve one startup redirect after authoritative settings/workspace
|
||||
bootstrap. Expose completion from `useKanbanRouteBootstrap`, including
|
||||
failed-fetch and empty-workspace completion. Keep cancellation tied to route
|
||||
identity. Preserve the local view hook's real `loaded` value rather than
|
||||
setting it to true during a rendering-mode update.
|
||||
- Preserve `isExplicitHomeDestination`, local view storage/fallbacks,
|
||||
explicit `linkToTaskOverview`, and path-preserving `listingHistoryHref`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Settings radio/copy, saved Threads filter/session defaults, task Back
|
||||
destination changes, mobile topbar geometry, swipe timing, and new stores.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every generic Home consumer honors Threads in the selected non-Office
|
||||
workspace; Office remains Office, and existing choices preserve current
|
||||
behavior, including the palette's legacy target.
|
||||
- Bare Home chooses Threads despite a competing local view or unavailable
|
||||
storage, with no earlier wrong-route redirect during settings/workspace
|
||||
hydration. Empty/error/no-workspace paths settle without loops.
|
||||
- Explicit task/session/workflow/overview/listing links and history remain
|
||||
usable; view toggles never change the portable default, last-task resume
|
||||
stays workspace-local/startup-only, and phone Pipeline fallback is not saved.
|
||||
|
||||
## Verification
|
||||
|
||||
Task 01 has already installed dependencies. Run each line from the repo root:
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm --filter @kandev/web test lib/startup-page.test.ts lib/task-listing/view-preference.test.ts lib/task-listing/view-navigation.test.ts hooks/use-task-listing-view.test.tsx app/page-client.test.tsx)
|
||||
(cd apps && pnpm --filter @kandev/web test lib/navigation/workspace-home.test.ts lib/navigation/resolve-destinations.test.ts hooks/use-app-destinations.test.tsx hooks/use-home-affordance.test.ts components/app-sidebar/app-sidebar-workspace-navigation.test.ts src/kanban-route.test.ts src/kanban-route-startup.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`workspace-home.test.ts` and `kanban-route-startup.test.tsx` are new files.
|
||||
The latter covers deferred settings, stale workspace fetches, Office priority,
|
||||
and empty/error readiness using real route wiring with mocked transport.
|
||||
Browser entry-point proof is owned by Task 03; pure href tests alone do not
|
||||
complete the feature.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/startup-page.ts` and `startup-page.test.ts`
|
||||
- `apps/web/app/page-client.tsx` and `page-client.test.tsx`
|
||||
- `apps/web/src/kanban-route.tsx`, existing `kanban-route.test.ts`, and new
|
||||
`kanban-route-startup.test.tsx`
|
||||
- `apps/web/lib/navigation/workspace-home.ts` and new `workspace-home.test.ts`
|
||||
- `apps/web/lib/navigation/core-destinations.ts`, `types.ts`, and
|
||||
`resolve-destinations.test.ts`
|
||||
- `apps/web/hooks/use-app-destinations.ts`, `use-app-destinations.test.tsx`,
|
||||
and `use-home-affordance.test.ts`
|
||||
- `apps/web/hooks/use-task-listing-view.ts` and `use-task-listing-view.test.tsx`
|
||||
- `apps/web/lib/task-listing/view-preference.test.ts` and `view-navigation.test.ts`
|
||||
- `apps/web/components/app-sidebar/app-sidebar-header.tsx`,
|
||||
`app-sidebar-primary-nav.tsx`, `app-sidebar-footer.tsx`,
|
||||
`app-sidebar-workspace-picker.tsx`, and `app-sidebar-workspace-navigation.test.ts`
|
||||
- `apps/web/components/navigation/app-nav-sections.tsx` (shared phone Home consumer)
|
||||
|
||||
`view-preference.ts`, `view-navigation.ts`, and `use-home-affordance.ts` already
|
||||
own the correct lower-level contracts; change their production logic only if
|
||||
needed for the shared resolver, not to move startup policy into view memory.
|
||||
|
||||
## Dependencies
|
||||
|
||||
[Task 01](task-01-persist-threads-startup-choice.md).
|
||||
|
||||
## Risks
|
||||
|
||||
- Root startup cannot distinguish generic Home from explicit overview by
|
||||
pathname alone; use entry intent rather than an unconditional redirect.
|
||||
- Gating on a workflow/snapshot deadlocks empty workspaces. Gating only on the
|
||||
local view hook's old `loaded: true` write races settings hydration.
|
||||
- A workspace picker must pass its selected workspace, not a stale active one.
|
||||
- The phone menu Home row owns listing Home; task Task overview is a
|
||||
separate explicit link and must not be swept into this change.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Design sections **Entry resolution**, **Home consumers**, **Remembered
|
||||
listing and list details**, and **Failure and recovery**.
|
||||
- Existing view preference/navigation tests and page-client startup tests.
|
||||
- Existing `useHomeAffordance` and sidebar navigation tests.
|
||||
- `apps/web/AGENTS.md`, `/mobile-parity`, ADR 0023, and Office-mode ADR.
|
||||
|
||||
## Results
|
||||
|
||||
Shared Home policy now covers the manifest, palette, sidebar, settings exit,
|
||||
workspace picker, and phone Home. Bare startup waits for authoritative route
|
||||
bootstrap; explicit destinations and Office retain priority. The device view
|
||||
hook preserves settings readiness.
|
||||
|
||||
RED: 11 destination/page/hook assertions and four bootstrap assertions failed
|
||||
for the expected missing behavior. GREEN: the listed suites plus existing SPA
|
||||
workspace and sidebar/header neighbors passed (16 files, 148 tests). Typecheck,
|
||||
focused ESLint, and `git diff --check` passed. The SPA workspace test now waits
|
||||
for bootstrap before asserting the selected workspace. Browser proof follows
|
||||
in Task 03.
|
||||
|
||||
### PR review remediation
|
||||
|
||||
UI remains the owner of Home presentation; Office availability retains its
|
||||
existing feature-gate ownership. AC-003.6 and the Home-consumer design were
|
||||
clarified before repairing the phone brand's disabled-Office destination. Reuse the
|
||||
existing brand as direct navigation into the native task listing. Layout,
|
||||
touch targets, safe areas, scroll ownership, and the native menu/deck exemplars
|
||||
remain unchanged; no parent topbar or swipe work is included.
|
||||
|
||||
RED: the two disabled-Office header cases and phone href assertion resolved
|
||||
to `/office` instead of the expected task listing. The stale workspace-picker
|
||||
mock also reproduced 23 crashes before repair. GREEN: gate the phone's Office
|
||||
record with the existing feature flag, preserving its workspace ID fallback;
|
||||
restore complete fixture inputs and cover Threads selection and Office priority.
|
||||
Direct no-workspace and explicit-overview cases document existing resolver
|
||||
behavior, with a short production invariant comment.
|
||||
|
||||
Verification from `apps/web`:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run components/kanban/kanban-header-mobile.test.tsx components/app-sidebar/app-sidebar-workspace-picker.test.tsx lib/startup-page.test.ts e2e/helpers/api-client.test.ts lib/navigation/workspace-home.test.ts hooks/use-home-affordance.test.ts src/kanban-route-startup.test.tsx app/page-client.test.tsx
|
||||
pnpm run typecheck
|
||||
pnpm exec eslint --max-warnings 0 components/kanban/kanban-header-mobile.tsx components/kanban/kanban-header-mobile.test.tsx components/app-sidebar/app-sidebar-workspace-picker.test.tsx lib/startup-page.ts lib/startup-page.test.ts e2e/helpers/api-client.ts e2e/helpers/api-client.test.ts e2e/tests/office/mobile-office-navigation.spec.ts
|
||||
```
|
||||
|
||||
All 88 focused tests passed. After completing the typed header fixture and
|
||||
splitting its describe blocks to meet the lint limit, all 13 header tests
|
||||
passed again. Typecheck and focused ESLint passed. Task 03 records the rebuilt
|
||||
browser proof. Specification lint and its 30 validator tests passed.
|
||||
|
||||
### CI navigation-fixture remediation
|
||||
|
||||
The full frontend CI suite exposed three additional stale store fixtures in
|
||||
the primary sidebar, phone navigation sheet, and integrations menu tests.
|
||||
All 32 failures reproduced locally because the shared Home context now reads
|
||||
`userSettings.startupPage`. Restore the complete settings slice from the
|
||||
existing default state and reset mutable settings between cases. Extend the
|
||||
existing desktop and phone Home assertions to cover the Threads choice.
|
||||
|
||||
This is test-only compatibility repair. Requirements, system design, public
|
||||
copy, production routing, and native surface geometry remain unchanged.
|
||||
|
||||
Verification from the repo root:
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm exec vitest run components/app-sidebar/app-sidebar-primary-nav.test.tsx components/navigation/app-nav-sheet.test.tsx components/integrations/integrations-menu.test.ts)
|
||||
(cd apps/web && pnpm exec eslint --max-warnings 0 components/app-sidebar/app-sidebar-primary-nav.test.tsx components/navigation/app-nav-sheet.test.tsx components/integrations/integrations-menu.test.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps && NODE_ENV=production pnpm --filter @kandev/web test)
|
||||
```
|
||||
|
||||
The pre-merge focused run passed all 34 tests, focused ESLint, and typecheck.
|
||||
Main integration then required the UI index to retain both this design and the
|
||||
new control-sizing design. The merged routing/settings suite passed 122 tests
|
||||
in 11 files, followed by full web lint, typecheck, i18n checks, tagged/race Go
|
||||
startup and boot-mapping tests, and the rebuilt browser checks in Task 03.
|
||||
Harness validation passed 19 tests and all 196 files; specification validation
|
||||
passed 36 tests and all specs. The PR-only diff is whitespace-clean; staged
|
||||
whitespace warnings were verified as unchanged incoming main content.
|
||||
|
||||
The first merged full-suite attempt could not open a tsx IPC socket inside the
|
||||
agent sandbox (`EPERM`) and was stopped without a verdict. Full-suite execution
|
||||
requires local IPC permission in this environment. Final broad-suite and
|
||||
current-head CI/review results belong to the PR delivery evidence; the scoped
|
||||
remediation checks above are complete.
|
||||
|
||||
### CI live-filter readiness remediation
|
||||
|
||||
E2E shard 7 exposed four step-visibility failures after selecting All Workflows.
|
||||
The hydrated boot path returns without recording completion; clearing the live
|
||||
workflow filter then makes the snapshot predicate false and unmounts the board
|
||||
behind Loading Home. The exact first scenario also fails locally with no retries.
|
||||
|
||||
Keep completion settled for the same route on both hydration paths. Add focused
|
||||
tests for All Workflows, an uncached workflow, and a subsequent explicit route
|
||||
change that must still wait for its own bootstrap. Re-run the failed filter
|
||||
spec and the affected desktop/phone startup neighbors against a rebuilt bundle.
|
||||
This repairs AC-003.5 without changing default precedence, public copy, native
|
||||
layout, or the parent's topbar/swipe work. Before the later main merge, the two
|
||||
new unit assertions failed and then passed with 37 route/bootstrap tests; the
|
||||
rebuilt filter/startup E2E run passed 12 tests with no retries. Shard 5 later
|
||||
confirmed four more failures in `workflow-filter.spec.ts` with the same Loading
|
||||
Home evidence; include that complete spec in post-merge verification.
|
||||
|
||||
### Landed parent and fixture integration
|
||||
|
||||
The parent polish landed on main and removed the phone brand link. Keep its
|
||||
header and header tests unchanged; Home now belongs to the shared menu's
|
||||
`AppNavSections`, which already uses this task's saved startup choice and
|
||||
feature-aware Office context. Update both phone startup/Office scenarios to
|
||||
tap that actual Home row, retaining workspace, destination, and reload checks.
|
||||
The earlier brand-specific test evidence above is historical; menu-based
|
||||
verification supersedes it. Public how-to documentation retains both the
|
||||
parent's compact-menu guidance and this task's three startup choices.
|
||||
|
||||
The mobile parked-background-work CI flake used a suite-level profile ID that
|
||||
per-test cleanup deleted when the seed already existed. Initial fresh-worker
|
||||
runs passed three times; seeding before provider restart reproduced the exact
|
||||
missing-session failure. Create a fresh mock profile after `testPage` cleanup.
|
||||
The existing scenario and its preceding file-viewer case passed twice (four
|
||||
tests, no retries). This is fixture-only repair; parked-work requirements and
|
||||
production behavior remain unchanged.
|
||||
|
||||
Post-merge verification from `apps/web` passed 109 tests across 13 files:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run src/kanban-route-startup.test.tsx src/kanban-route.test.ts lib/routing/kanban-route-hydration.test.ts app/page-client.test.tsx components/kanban/kanban-header-mobile.test.tsx components/navigation/app-nav-sheet.test.tsx hooks/use-app-destinations.test.tsx lib/navigation/core-destinations.test.ts lib/navigation/workspace-home.test.ts
|
||||
pnpm exec vitest run hooks/use-in-office.test.ts components/kanban/mobile-menu-sheet.test.tsx components/kanban/mobile-menu-utility-actions.test.tsx components/workspace-scope-provider.test.tsx
|
||||
pnpm run lint
|
||||
pnpm run typecheck
|
||||
pnpm run i18n:check
|
||||
```
|
||||
|
||||
All commands passed. The 12 startup tests passed again after explicitly asserting
|
||||
the new route's resolved workspace. Focused ESLint over the changed E2E tests
|
||||
also passed. Task 03 records the 73 rebuilt desktop/phone browser checks. All
|
||||
specification, harness, and public-docs validators passed; PR-only whitespace
|
||||
checks passed. Current-head remote CI/review remains delivery work after push.
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
id: "03-expose-threads-home-choice"
|
||||
title: "Expose the Threads Home choice"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-resolve-threads-home-navigation"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.1
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.2
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.3
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.4
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.1
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.2
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.6
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.7
|
||||
- AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.8
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-listing-display-preferences.md
|
||||
---
|
||||
|
||||
# Task 03: Expose the Threads Home Choice
|
||||
|
||||
## Summary
|
||||
|
||||
Expose the saved Threads choice in the existing Startup Page radio card and
|
||||
prove it through desktop and native phone navigation. Publish accurate help
|
||||
copy and task-listing documentation with the finished behavior.
|
||||
|
||||
## In scope
|
||||
|
||||
- Use `/tdd`, `/e2e`, and `/mobile-parity`. Start with failing desktop and phone
|
||||
tests for selecting the missing Threads radio option.
|
||||
- Add Threads to the existing card; use Appearance draft/save/discard/error
|
||||
coordination. Add pure draft/patch/rebase tests for the new value, including
|
||||
preservation of unrelated settings and edits during live updates.
|
||||
- Revise the startup descriptions and discovery aliases. Author English,
|
||||
pt-pt, and zh-cn; generate zh-hk/zh-tw and pseudo. Use `t()` at render time.
|
||||
- Implement the plan's named E2E flows in the existing startup specs. Extend
|
||||
Home-focused Office navigation cases for the new choice. Keep device-memory
|
||||
and legacy last-task scenarios as regressions.
|
||||
- Update `docs/public/tasks-and-workflows.md` using `/docs-maintainer`; retain
|
||||
its how-to focus and existing page route. Record rendered phone evidence.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A new Settings page or save mechanism, parent demo data, media production,
|
||||
topbar normalization, swipe feedback, or new Threads deck functionality.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The localized third option is discoverable on desktop and phone, stays a
|
||||
draft until saved, can be discarded, and retains a retryable draft on failed
|
||||
save without claiming a new durable default.
|
||||
- Real UI flows prove Home, startup/reload, workspace/Office behavior, explicit
|
||||
route protection, and portable persistence with an independent browser
|
||||
context while all original defaults remain valid.
|
||||
- Phone taps reach the existing native deck; settings rows and Save are
|
||||
reachable with safe-area clearance and no document overflow. Public help
|
||||
matches shipped behavior and all locale checks pass.
|
||||
|
||||
## Verification
|
||||
|
||||
Dependencies are installed by Task 01. Run each line from the repo root. The
|
||||
managed E2E commands rebuild production artifacts and isolate test data; use
|
||||
one project per invocation, sequentially. Do not point tests at parent demos.
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm run i18n:zh-hant)
|
||||
(cd apps/web && pnpm run i18n:pseudo)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
(cd apps && pnpm --filter @kandev/web test components/settings/appearance-settings-state.test.ts components/settings/general-settings.test.tsx components/settings/settings-save-provider.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm exec eslint components/settings/startup-page-settings-card.tsx components/settings/general-settings.tsx components/settings/appearance-settings-state.ts e2e/tests/settings/startup-page.spec.ts e2e/tests/settings/mobile-startup-page.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/settings/startup-page.spec.ts tests/task/task-listing-view-preferences.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-startup-page.spec.ts tests/task/mobile-task-listing-display.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/office/sidebar-navigation.spec.ts -- --grep 'Home')
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/office/mobile-office-navigation.spec.ts)
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
New shared browser helpers, if extracted, join the focused eslint invocation.
|
||||
Confirm test discovery and record pass counts. Inspect a rendered phone
|
||||
viewport or saved Playwright screenshot of the selected row and Threads
|
||||
destination; record its artifact path. No separate media seed/capture task is
|
||||
needed for normal test evidence.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/settings/startup-page-settings-card.tsx`
|
||||
- `apps/web/components/settings/general-settings.tsx` (copy wiring only if needed)
|
||||
- New `apps/web/components/settings/appearance-settings-state.test.ts`
|
||||
- Existing `apps/web/components/settings/general-settings.test.tsx` if needed
|
||||
for save-error or contributor wiring; do not add markup-only snapshot tests
|
||||
- `apps/web/src/locales/en/settings.json`, `pt-pt/settings.json`,
|
||||
`zh-cn/settings.json`, generated `zh-hk/settings.json`, `zh-tw/settings.json`,
|
||||
and `pseudo/settings.json`
|
||||
- `apps/web/lib/settings-discovery/catalog/preferences.ts` only if the existing
|
||||
alias key cannot carry the additional search terms
|
||||
- `apps/web/e2e/tests/settings/startup-page.spec.ts`,
|
||||
`mobile-startup-page.spec.ts`, and a shared `startup-page-helpers.ts` if needed
|
||||
- `apps/web/e2e/tests/office/sidebar-navigation.spec.ts` and
|
||||
`mobile-office-navigation.spec.ts`
|
||||
- `docs/public/tasks-and-workflows.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
[Task 02](task-02-resolve-threads-home-navigation.md).
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing copy says Home always opens overview and must be revised together
|
||||
with the new option. The Task overview description must include remembered
|
||||
Threads as well as Kanban/Pipeline/List.
|
||||
- The startup card is mocked out in `general-settings.test.tsx`; those tests
|
||||
cannot alone prove the new radio exists or is touch-usable.
|
||||
- Worker-scoped settings leak if a test fails before cleanup. Restore captured
|
||||
baselines in `afterEach`, and avoid fixture resets in a second browser context.
|
||||
- New UI strings need every shipped locale; older contradictory prose in
|
||||
`docs/i18n.md` does not override current root/scoped AGENTS enforcement.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Design **Settings and mobile composition**, **Entry resolution**, and
|
||||
**Failure and recovery**; plan **E2E tests** supplies the named scenarios.
|
||||
- Shipped `StartupPageSettingsCard`, `AppearanceState`, `SettingsSaveProvider`,
|
||||
`MobileMenuSheet`, and existing startup-page desktop/mobile tests.
|
||||
- `/e2e` fixture/cleanup guidance, `/mobile-parity` mobile UI language,
|
||||
`/docs-maintainer`, and `apps/web/AGENTS.md`.
|
||||
|
||||
## Results
|
||||
|
||||
Desktop and phone RED runs both failed on the missing Threads radio before
|
||||
the card and locale changes. The existing Appearance contributor and shared
|
||||
Save mechanism need no production changes; the new option uses them directly.
|
||||
|
||||
- Draft/patch/rebase, general settings, and Save provider: 35 tests passed.
|
||||
The consolidated frontend run passed 272 tests across 21 files, covering
|
||||
settings transport, navigation, bootstrap, and existing listing behavior.
|
||||
- Typecheck and focused ESLint passed. Locale generation, `i18n:check`, and
|
||||
`i18n:ratchet` passed for all five languages and pseudo. Unrelated
|
||||
pre-existing Traditional Chinese wording was preserved after generation.
|
||||
- Desktop startup and listing E2E: 8 passed. Real UI Save, Reset, failed Save
|
||||
and Retry, independent browser persistence, all Home entry points, workspace
|
||||
isolation, explicit destinations, and history passed with existing defaults.
|
||||
- Phone startup and listing E2E: 5 passed. The native menu, labelled row,
|
||||
touch Save, settings/Home reloads, focused task/session route, viewport
|
||||
containment, and original last-task and Pipeline fallback flows passed.
|
||||
- Desktop Office Home E2E: 3 passed, including the shared Appearance page.
|
||||
- Phone Office E2E: 3 passed after the final rebuild, including Settings Home
|
||||
retaining Office priority and switching to a non-Office Threads workspace.
|
||||
- Public docs validation passed for 46 pages; its validator test passed.
|
||||
Specification lint and `git diff --check` passed.
|
||||
|
||||
Browser runs used the managed host runner, one worker/project at a time,
|
||||
strict WS assertions, and `--retries=0`. After the production rebuild, unchanged
|
||||
artifacts were reused with `--no-build`; `E2E_PORT_OFFSET=19` isolated subsequent
|
||||
runs from the initial disconnected runtime. A writable temporary `GOCACHE`
|
||||
was used for backend builds. Empty List assertions target its real empty
|
||||
state. Phone hit testing waits for the transient update toast to clear without
|
||||
extending the assertion timeout or changing product layout.
|
||||
|
||||
The phone screenshots were visually inspected and copied outside the runner's
|
||||
cleaned output directory to `/tmp/kandev-threads-home-evidence.CTXlH8/`:
|
||||
|
||||
- `threads-home-settings-phone.png`: all three labelled choices, selected
|
||||
Threads, and the reachable shared Save action.
|
||||
- `threads-home-deck-phone.png`: native Threads Home with an empty workspace.
|
||||
- `threads-home-focused-phone.png`: one full-width conversation preserving
|
||||
task and session focus, with no horizontal document overflow.
|
||||
|
||||
These are temporary local verification artifacts, not public media. Public
|
||||
documentation was updated at `docs/public/tasks-and-workflows.md`.
|
||||
|
||||
### PR review remediation
|
||||
|
||||
The E2E API helper's settings response now declares `workspace_id` and
|
||||
`workflow_filter_id` as optional strings, matching the existing response and
|
||||
save contracts. A targeted TypeScript compiler assertion over the startup
|
||||
spec reproduced TS2322 at both cleanup fields before the declaration fix and
|
||||
passed afterward. The normal web typecheck excludes E2E files.
|
||||
|
||||
`pnpm exec vitest run e2e/helpers/api-client.test.ts` passed both helper tests,
|
||||
including contract coverage for reading and restoring the startup choice and
|
||||
workspace scope. Focused ESLint over both helper files passed. This helper
|
||||
change is test-only; Task 02 records the separate disabled-Office routing fix.
|
||||
|
||||
After `make build-web`, the following command from `apps/web` passed all seven
|
||||
phone scenarios with strict WebSocket checks and retries disabled:
|
||||
|
||||
```bash
|
||||
E2E_PORT_OFFSET=21 pnpm e2e:run --host --no-build --project mobile-chrome tests/office/mobile-office-navigation.spec.ts tests/settings/mobile-startup-page.spec.ts -- --retries=0
|
||||
```
|
||||
|
||||
The new disabled-Office scenario proved brand tap, workspace retention, and
|
||||
Threads reload. Its `office-disabled-threads-phone.png` screenshot was inspected.
|
||||
The explicit-destination scenario completed in 11.0 seconds; reducing its causal
|
||||
session-wait budget would only fail a slow precondition earlier, so no speculative
|
||||
timeout change was made. Runtime environment is restored in `finally` and saved
|
||||
preferences remain restored by the suite's existing cleanup.
|
||||
|
||||
Public how-to documentation now explicitly names Kanban/Pipeline selections and
|
||||
disabled-Office Home behavior. All verification blocks use scoped directory
|
||||
changes. Public-docs validation passed for 46 pages and its validator test passed.
|
||||
|
||||
### Earlier current-main integration verification
|
||||
|
||||
The managed host runner rebuilt the backend, web bundle, and packaged plugin
|
||||
fixture after main integration. The following commands from `apps/web` passed
|
||||
seven phone scenarios followed by 14 desktop scenarios, with one worker, strict
|
||||
WebSocket checks, and retries disabled. The desktop run reused only the freshly
|
||||
built, unchanged artifacts from the preceding phone run.
|
||||
|
||||
```bash
|
||||
E2E_PORT_OFFSET=21 pnpm e2e:run --host --project mobile-chrome tests/office/mobile-office-navigation.spec.ts tests/settings/mobile-startup-page.spec.ts -- --retries=0
|
||||
E2E_PORT_OFFSET=21 pnpm e2e:run --host --no-build --project chromium tests/office/sidebar-navigation.spec.ts tests/settings/startup-page.spec.ts -- --retries=0
|
||||
```
|
||||
|
||||
Both runs used a task-owned writable Go cache and `GOMAXPROCS=4`. The 21 passing
|
||||
scenarios cover desktop and native phone Home entry points, explicit routes,
|
||||
workspace retention, preference persistence, and the disabled-Office fallback.
|
||||
All managed fixtures stopped after the runs; the main instance and parent demo
|
||||
data were not used.
|
||||
|
||||
### Landed-parent and CI repair verification
|
||||
|
||||
After parent polish landed, phone Home uses the shared menu instead of the
|
||||
removed brand link. The startup and disabled-Office scenarios now tap that
|
||||
Home row. Existing touch/layout, workspace, preference, and reload assertions
|
||||
remain intact. Task 02 records the readiness and test-profile cleanup repairs.
|
||||
|
||||
From `apps/web`, the following commands passed 25 desktop and 48 phone tests:
|
||||
|
||||
```bash
|
||||
E2E_PORT_OFFSET=21 pnpm e2e:run --host --project chromium tests/kanban/step-visibility-filter.spec.ts tests/kanban/workflow-filter.spec.ts tests/settings/startup-page.spec.ts tests/office/sidebar-navigation.spec.ts -- --retries=0
|
||||
E2E_PORT_OFFSET=21 pnpm e2e:run --host --no-build --project mobile-chrome tests/office/mobile-office-navigation.spec.ts tests/settings/mobile-startup-page.spec.ts tests/kanban/mobile-kanban-topbar.spec.ts tests/kanban/mobile-kanban.spec.ts tests/task/mobile-threads-view.spec.ts tests/task/mobile-parked-background-work.spec.ts -- --retries=0
|
||||
```
|
||||
|
||||
One worker, strict WebSocket checks, a task-owned Go cache, and `GOMAXPROCS=4`
|
||||
were used. The desktop command rebuilt backend/web/plugin artifacts; the phone
|
||||
command reused those unchanged artifacts. All eight failed CI filter cases
|
||||
and the mobile cleanup regression passed without retries. Fresh post-commit
|
||||
PR screenshots and exact-head CI/review results remain delivery evidence.
|
||||
@@ -395,10 +395,11 @@ deletion is not success.
|
||||
|
||||
On desktop and tablet, the header switches between **Kanban**, **Pipeline**,
|
||||
**Threads**, 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**, **Threads**, and **List** in the
|
||||
topbar menu; a saved desktop Pipeline preference is kept but shown as Kanban
|
||||
on the phone.
|
||||
different layouts. Threads shows agent conversations side by side. Kandev
|
||||
remembers the last selected view in that browser on the current device. Phones
|
||||
offer **Kanban**, **Threads**, and **List** in the topbar menu, with a native
|
||||
Threads deck showing one conversation at a time. A saved desktop Pipeline
|
||||
preference is kept but shown as Kanban on the phone.
|
||||
|
||||
Kanban and List share a compact phone header showing the workspace and current
|
||||
mode. Tap that context or the menu button to change views, workspaces, or display
|
||||
@@ -407,7 +408,15 @@ Threads view. Phone **Search tasks** lives in the menu: selecting it reveals
|
||||
and focuses the search field below the header. Selecting it again hides the
|
||||
field and clears the query.
|
||||
|
||||
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.
|
||||
Under **Settings → Preferences → Appearance → Startup Page**, choose a destination, then select **Save changes**:
|
||||
|
||||
- **Task overview** (the default): open the last listing view used on this device, including Threads.
|
||||
- **Last visited task**: resume the most recently opened task in the current workspace on this device when Kandev starts or you open the bare home address. If no matching task exists, open the remembered listing instead. Home navigation does not resume the task.
|
||||
- **Threads**: always open Threads on startup and Home navigation in the selected workspace, even after using a different listing view. This saved choice follows your user across devices; changing a listing view does not change it.
|
||||
|
||||
Office workspaces keep their Office Home while Office is enabled. With Office disabled, Home keeps the workspace and uses the task-listing startup choice.
|
||||
|
||||
Explicit view selections (including Kanban, Pipeline, and List), task, session, workflow, overview, and focused Threads links keep their destination on reload instead of applying the saved Threads default. A task's **Task overview** or Back action still opens the overview family using the remembered listing; it does not apply the fixed Threads default.
|
||||
|
||||
The **TASKS** list in the left sidebar has two time-based sort choices. These choices are separate from the sort choices in the task **List** view.
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ owners:
|
||||
|
||||
## Scope
|
||||
|
||||
UI owns responsive behavior; other systems own behavior/state.
|
||||
UI owns responsive behavior and reusable presentation preferences, including
|
||||
task-listing modes and Home destination choices. Other systems retain task,
|
||||
runtime, and workspace lifecycle/state ownership.
|
||||
|
||||
## Map
|
||||
|
||||
@@ -132,7 +134,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
||||
- [Task Layout Profiles](requirements/task-layout-profiles.md)
|
||||
- [Task Agent Tab Reconciliation](requirements/task-agent-tab-reconciliation.md)
|
||||
- [Threads Deck](requirements/threads-conversation-deck.md)
|
||||
- [Task listing](requirements/task-listing-display-preferences.md)
|
||||
- [Task Listing Display Preferences](requirements/task-listing-display-preferences.md)
|
||||
- [Task transcript history visibility](requirements/task-prompt-transcript-visibility.md)
|
||||
- [Task Review Shortcut Switcher](requirements/task-review-shortcut.md)
|
||||
- [Task Surface Refresh](requirements/task-surface-refresh.md)
|
||||
@@ -204,6 +206,7 @@ UI owns responsive behavior; other systems own behavior/state.
|
||||
- [Saved Task View Deletion Confirmation](system-design/saved-task-view-deletion-confirmation.md)
|
||||
- [Session](system-design/session-start-composer-readiness.md)
|
||||
- [Task Layout Profiles](system-design/task-layout-profiles.md)
|
||||
- [Task Listing Display Preferences](system-design/task-listing-display-preferences.md)
|
||||
- [Task surface](system-design/task-surface-render-isolation.md)
|
||||
- [Task Agent Tab Reconciliation](system-design/task-agent-tab-reconciliation.md)
|
||||
- [Threads Deck](system-design/threads-conversation-deck.md)
|
||||
|
||||
@@ -9,200 +9,153 @@ owners:
|
||||
|
||||
## Overview
|
||||
|
||||
People browse the same tasks in different ways depending on the device and the amount of detail they need. A phone user may consistently prefer the compact list, while a desktop user may prefer Kanban or Pipeline, and list users need an optional richer row without losing the current compact default.
|
||||
People can remember their last task-listing mode on each device or explicitly
|
||||
choose Threads as their Home destination. Choosing another mode for the current
|
||||
interaction must not replace that explicit default. List users can also choose
|
||||
richer rows without losing the compact default.
|
||||
|
||||
UI owns this reusable presentation and navigation preference contract. It does
|
||||
not change task lifecycle or workspace ownership. The Threads destination uses
|
||||
the existing [conversation deck](threads-conversation-deck.md) and
|
||||
[saved views](threads-saved-views.md).
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Remembered listing:** The most recently used Kanban, Pipeline, List, or
|
||||
Threads mode in this browser on this device.
|
||||
- **Startup choice:** The user's saved choice of Task overview, Last visited
|
||||
task, or Threads in Settings > Preferences > Appearance > Startup Page.
|
||||
- **Bare Home:** Opening Kandev's root without an explicit task, session,
|
||||
workflow, or overview destination. Naming only a workspace still permits the
|
||||
startup choice to apply.
|
||||
- **Home action:** A Home link, brand link, Home command, or settings exit that
|
||||
returns to the active workspace's home. An explicit Task overview or Back
|
||||
destination remains separate.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001: Task Listing Display Preferences
|
||||
|
||||
**Intent:** People browse the same tasks in different ways depending on the device and the amount of detail they need. A phone user may consistently prefer the compact list, while a desktop user may prefer Kanban or Pipeline, and list users need an optional richer row without losing the current compact default.
|
||||
**Intent:** Preserve independent device presentation choices and portable list
|
||||
detail settings.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.1:** Selecting **All Workflows** remains selected in Kanban and List even when the workspace has exactly one visible workflow.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.2:** The selected workflow filter continues to use the existing portable user setting and survives navigation and reloads.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.3:** The most recently selected task-listing view is one of `kanban`, `pipeline`, or `list` and is stored on the current device.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.4:** Opening the Home task surface restores the device's last selected view.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.5:** A missing, invalid, or unavailable device preference falls back to Kanban.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.3:** The remembered listing shall
|
||||
support Kanban, Pipeline, List, and Threads, survive browser restarts, and
|
||||
remain device-local rather than syncing through the user's portable settings.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.4:** When no fixed Home default or
|
||||
eligible startup task applies, opening the Home task surface shall restore
|
||||
the remembered listing.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.5:** An invalid remembered listing
|
||||
shall fall back to Kanban. A missing or unavailable preference shall also
|
||||
fall back to Kanban unless the existing legacy Pipeline preference supplies
|
||||
the compatibility fallback.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.6:** Pipeline remains unavailable on phone-sized layouts. A phone temporarily renders Kanban when Pipeline is the saved device preference without overwriting that saved preference.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.7:** List view adds a **Show task details** display option. It is disabled by default and appears in both desktop and mobile display-options surfaces while List is active.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.8:** Enabling **Show task details** enriches each list row with the same useful task context surfaced by Kanban cards when that data exists: repository slug chips, a truncated description, pull-request status, session count, parent-task context, and review-attention state.
|
||||
|
||||
## Migrated source detail
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.9:** With task details disabled,
|
||||
compact rows shall retain their title, task-state icon, updated time, and
|
||||
actions. Missing rich metadata shall omit only that metadata, not the row.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.10:** The richer-row choice shall
|
||||
survive reloads and apply on the user's other signed-in devices. After a
|
||||
failed save, an authoritative reload shall restore the persisted value.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.11:** Clicking or tapping a rich
|
||||
row shall open its task. Archive, delete, and interactive metadata actions
|
||||
shall remain independent, without horizontal document overflow on phones.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-001.12:** When browser storage rejects
|
||||
a view change, the chosen view shall remain usable for the current document;
|
||||
reopening the app shall use the available saved preference or fallback.
|
||||
|
||||
## Why
|
||||
### REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002: Startup compatibility
|
||||
|
||||
People browse the same tasks in different ways depending on the device and the
|
||||
amount of detail they need. A phone user may consistently prefer the compact
|
||||
list, while a desktop user may prefer Kanban or Pipeline, and list users need an
|
||||
optional richer row without losing the current compact default.
|
||||
**Intent:** Keep existing startup choices and workspace-local recent-task
|
||||
behavior while extending the choice with Threads.
|
||||
|
||||
## What
|
||||
#### Acceptance criteria
|
||||
|
||||
- Selecting **All Workflows** remains selected in Kanban and List even when the
|
||||
workspace has exactly one visible workflow.
|
||||
- The selected workflow filter continues to use the existing portable user
|
||||
setting and survives navigation and reloads.
|
||||
- The most recently selected task-listing view is one of `kanban`, `pipeline`,
|
||||
or `list` and is stored on the current device.
|
||||
- Opening the Home task surface restores the device's last selected view.
|
||||
- A missing, invalid, or unavailable device preference falls back to Kanban.
|
||||
- Pipeline remains unavailable on phone-sized layouts. A phone temporarily
|
||||
renders Kanban when Pipeline is the saved device preference without
|
||||
overwriting that saved preference.
|
||||
- List view adds a **Show task details** display option. It is disabled by
|
||||
default and appears in both desktop and mobile display-options surfaces while
|
||||
List is active.
|
||||
- Enabling **Show task details** enriches each list row with the same useful
|
||||
task context surfaced by Kanban cards when that data exists:
|
||||
repository slug chips, a truncated description, pull-request status, session
|
||||
count, parent-task context, and review-attention state.
|
||||
- Compact rows retain their current title, task-state icon, updated time, and
|
||||
actions when **Show task details** is disabled.
|
||||
- The richer-row option is a portable user preference and applies on the user's
|
||||
other signed-in Kandev devices.
|
||||
- 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.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.1:** Task overview shall remain
|
||||
the default startup choice. Existing users shall retain their saved Task
|
||||
overview or Last visited task choice.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.2:** With Last visited task
|
||||
selected, bare Home shall open the newest local recent task belonging to the
|
||||
resolved active workspace. A newer entry from another workspace shall not
|
||||
become the target.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.3:** If no matching local recent
|
||||
task is available, including when local storage cannot be read, Last visited
|
||||
task shall fall back to the remembered listing and its existing fallback.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.4:** Last visited task shall not
|
||||
resume a task on a Home action, a task's Back action, or an explicit task,
|
||||
session, workflow, or overview destination.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-002.5:** Missing or unsupported saved
|
||||
startup choices shall normalize to Task overview. An unsupported submitted
|
||||
choice shall fail without replacing the previous saved choice, and an
|
||||
unrelated settings save shall leave the startup choice unchanged.
|
||||
|
||||
## Data model
|
||||
### REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003: Explicit Threads Home default
|
||||
|
||||
### Device-local view preference
|
||||
**Intent:** Let a user consistently return to Threads even after using another
|
||||
task-listing mode.
|
||||
|
||||
`localStorage["kandev.taskListing.view.v1"]` contains a JSON string with one of:
|
||||
#### Acceptance criteria
|
||||
|
||||
- `"kanban"`
|
||||
- `"pipeline"`
|
||||
- `"list"`
|
||||
|
||||
The browser value is the durable source of truth for this explicitly
|
||||
device-local preference, consistent with
|
||||
[ADR 0041](../../../decisions/0041-backend-owned-portable-user-settings.md).
|
||||
Existing users with no local value may use their current
|
||||
`kanban_view_mode` once as a compatibility fallback before the local preference
|
||||
becomes authoritative.
|
||||
|
||||
### Portable richer-row preference
|
||||
|
||||
The existing backend-owned user settings object adds:
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
|---|---|---:|---|
|
||||
| `tasks_list_show_details` | boolean | `false` | Whether List renders enriched task rows. |
|
||||
|
||||
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,
|
||||
"startup_page": "task_overview"
|
||||
}
|
||||
```
|
||||
|
||||
The existing user-settings update request accepts an optional
|
||||
`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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If browser storage is blocked or full, changing views still navigates for the
|
||||
current interaction, but a future app open falls back to Kanban.
|
||||
- If an old or malformed device value is found, Kandev ignores it and uses
|
||||
Kanban.
|
||||
- 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.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
- The last selected task-listing view survives browser restarts on the same
|
||||
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.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a workspace with one visible workflow, **WHEN** the user selects
|
||||
**All Workflows** on Kanban, **THEN** the selector remains on **All
|
||||
Workflows** and the choice survives a reload.
|
||||
- **GIVEN** a workspace with multiple visible workflows, **WHEN** the user
|
||||
selects **All Workflows** in either Kanban or List, **THEN** tasks from every
|
||||
workflow remain visible after navigation and reload.
|
||||
- **GIVEN** List was the last view selected on a phone, **WHEN** the user opens
|
||||
Home again on that device, **THEN** List is selected and rendered.
|
||||
- **GIVEN** Pipeline was the last view selected on a desktop, **WHEN** the user
|
||||
opens Home again on that device, **THEN** Pipeline is selected and rendered.
|
||||
- **GIVEN** Pipeline is saved and the same browser is currently phone-sized,
|
||||
**WHEN** Home opens, **THEN** Kanban renders and the saved Pipeline value is
|
||||
unchanged.
|
||||
- **GIVEN** no valid device view preference, **WHEN** Home opens, **THEN**
|
||||
Kanban renders.
|
||||
- **GIVEN** **Show task details** is disabled, **WHEN** List renders tasks,
|
||||
**THEN** rows remain in the current compact presentation.
|
||||
- **GIVEN** **Show task details** is enabled, **WHEN** a task has repositories,
|
||||
a description, pull requests, or review/session metadata, **THEN** the
|
||||
available metadata is visible in its row without horizontal page overflow.
|
||||
- **GIVEN** a rich row on a phone, **WHEN** the user taps its body, **THEN** the
|
||||
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.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.1:** Startup Page shall offer
|
||||
Threads alongside Task overview and Last visited task on desktop and phone.
|
||||
Visible localized descriptions shall explain remembered overview behavior,
|
||||
startup-only task resume, and Threads applying to both startup and Home.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.2:** Selecting Threads shall use
|
||||
the existing shared Settings Save changes and discard flow. Only a successful
|
||||
save shall change the durable default; failures shall retain a retryable
|
||||
draft and the prior saved default. Discard shall restore the saved choice.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.3:** The saved Threads choice
|
||||
shall survive browser and Kandev restarts and apply on the user's other
|
||||
signed-in devices without requiring a local remembered Threads value.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4:** With Threads selected,
|
||||
bare Home and every Home action shall open Threads in the resolved workspace,
|
||||
including after the user has visited Kanban, Pipeline, or List. Saving the
|
||||
choice shall not itself change the remembered listing; subsequent view
|
||||
navigation shall not change the saved startup choice.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.5:** An explicit view selection,
|
||||
task, session, workflow, overview, or Threads focus link shall retain its
|
||||
destination and scope during navigation, reload, and browser history use.
|
||||
An explicit overview continues to mean the remembered task listing; the
|
||||
fixed Threads choice shall not override it. Saving a default while another
|
||||
destination is open shall not redirect that page.
|
||||
After startup settles, changing workflow filters, including All Workflows,
|
||||
shall keep the current listing usable rather than re-enter startup loading.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.6:** Home shall retain the active
|
||||
or explicitly requested workspace. Selecting another non-Office workspace
|
||||
shall apply Threads to that selected workspace. Office workspace Home shall
|
||||
continue to open Office while Office is enabled. With Office disabled, Home
|
||||
shall retain the workspace and honor the task-listing startup choice instead
|
||||
of linking to unavailable Office surfaces. No-workspace recovery shall remain
|
||||
available.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.7:** An empty Threads result
|
||||
shall stay in Threads and display its existing empty state. Unavailable or
|
||||
malformed browser listing storage shall not prevent a saved Threads Home
|
||||
choice from working.
|
||||
- **AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.8:** Phone users shall be able to
|
||||
select and save Threads with touch, use Home to reach the existing native
|
||||
mobile deck, and reload it. The settings option shall have an accessible
|
||||
label and a touch target at least 44 CSS pixels high, with visible save
|
||||
controls, safe-area clearance, and no horizontal document overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Syncing the last selected Kanban/Pipeline/List view between devices.
|
||||
- Syncing the remembered listing or exact recent task between devices.
|
||||
- Separate fixed defaults for Kanban, Pipeline, or List; those remain reachable
|
||||
through Task overview's remembered mode.
|
||||
- New per-workspace startup settings or changing workspace selection ownership.
|
||||
- Choosing a particular saved Threads filter, task, or session as Home.
|
||||
- Adding a phone-native Pipeline visualization.
|
||||
- User-configurable selection or ordering of individual rich-row metadata
|
||||
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.
|
||||
- Changing a task's explicit Back/Task overview destination to generic Home.
|
||||
- Swipe-indicator timing or mobile topbar normalization from the parent task.
|
||||
- Parent worktree changes, parent demo data, or launching other task sessions.
|
||||
- Removing the legacy `kanban_view_mode` API field in this change.
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
status: current
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002
|
||||
- REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003
|
||||
---
|
||||
|
||||
# Task Listing Display Preferences System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
UI owns the independent listing and Home presentation contract in the
|
||||
[requirements](../requirements/task-listing-display-preferences.md). Reuse
|
||||
backend-owned user settings for the explicit choice and retain browser-owned
|
||||
listing memory. Workspace selection and Office mode remain governed by
|
||||
[ADR 0023](../../../decisions/0023-active-workspace-cookie.md) and
|
||||
[Office mode ownership](../../../decisions/2026-08-15-office-mode-follows-active-workspace.md).
|
||||
|
||||
The Threads page uses its existing
|
||||
[conversation deck](threads-conversation-deck.md) and
|
||||
[saved views](threads-saved-views.md). This design does not alter their filters,
|
||||
session selection, resource budgets, swipe feedback, or topbar composition.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-001` | Preference ownership; Remembered listing and list details |
|
||||
| `REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-002` | Portable settings contract; Entry resolution; Failure and recovery |
|
||||
| `REQ-UI-TASK-LISTING-DISPLAY-PREFERENCES-003` | Portable settings contract; Entry resolution; Home consumers; Settings and mobile composition; Verification |
|
||||
|
||||
## Preference ownership
|
||||
|
||||
| Concept | Existing source | Scope | Values |
|
||||
| --- | --- | --- | --- |
|
||||
| Startup/default destination | `UserSettings.StartupPage`, wire `startup_page`, frontend `startupPage` | Portable per user | `task_overview` (default), `last_task`, `threads` |
|
||||
| Remembered listing | `localStorage["kandev.taskListing.view.v1"]` | Current browser/device, existing cross-workspace scope | JSON string `kanban`, `pipeline`, `list`, or `threads` |
|
||||
| Recent task target | `localStorage["kandev.recentTasks.v1"]` | Current device; target filtered by workspace | Existing recent-task entries |
|
||||
| Rich List rows | `tasks_list_show_details` / `tasksListShowDetails` | Portable per user | Boolean, default `false` |
|
||||
| Workflow filter | Existing `workflow_filter_id` / `workflowId` | Existing portable scope | Existing workflow ID or All Workflows |
|
||||
|
||||
Extend the existing startup enum instead of adding a parallel Home preference,
|
||||
local key, or workspace map. The existing startup setting already owns the
|
||||
explicit destination choice and the Settings save lifecycle. `threads` is the
|
||||
only new fixed destination. `task_overview` retains its remembered-mode
|
||||
meaning; `last_task` retains its startup-only behavior. Separate last-task
|
||||
startup plus fixed Threads Home is not introduced.
|
||||
|
||||
Saving `startup_page` does not call `setStoredTaskListingView`. View changes
|
||||
and the existing `/tasks` and `/threads` arrival effects may continue updating
|
||||
listing memory; they must never PATCH `startup_page`. Arriving in Threads via
|
||||
Home can therefore remember Threads as the latest visited mode without
|
||||
turning listing memory into the authority for the fixed default.
|
||||
|
||||
## Portable settings contract
|
||||
|
||||
`StartupPageThreads = "threads"` is defined in
|
||||
`apps/backend/internal/user/models/models.go`. `NormalizeStartupPage` preserves
|
||||
the three valid values and defaults absent or unsupported stored values to
|
||||
`task_overview`. `applyStartupPage` in `internal/user/service/service.go`
|
||||
accepts all three values, keeps current whitespace normalization, rejects an
|
||||
unsupported submitted value without changing the record, and leaves an omitted
|
||||
patch unchanged.
|
||||
|
||||
The existing user-settings HTTP read/update endpoints, DTO/controller mapping,
|
||||
`user.settings.updated` event, and revision-controlled write path remain the
|
||||
transport. DTO serialization, store scan/marshal, event publication, and
|
||||
`internal/backendapp/boot_state_routes.go` already call the backend normalizer;
|
||||
each must retain `threads` end to end. Settings stay in the existing users
|
||||
settings JSON on SQLite and PostgreSQL. No table, column, endpoint, or schema
|
||||
migration is added.
|
||||
|
||||
`StartupPage` in `apps/web/lib/types/http-user-settings.ts` and
|
||||
`parseStartupPage` in `apps/web/lib/ssr/user-settings.ts` retain all three values.
|
||||
The common wire-to-store mapper serves HTTP, save responses, and WebSocket updates. Keep
|
||||
revision ordering, omitted-field preservation, and default ownership from
|
||||
[ADR 0041](../../../decisions/0041-backend-owned-portable-user-settings.md).
|
||||
Do not introduce browser persistence for this field.
|
||||
|
||||
## Entry resolution
|
||||
|
||||
There are two decisions: generate a generic Home destination, or resolve a
|
||||
bare root entry. Explicit routing is not a third preference store.
|
||||
|
||||
| Entry in a non-Office workspace | `task_overview` | `last_task` | `threads` |
|
||||
| --- | --- | --- | --- |
|
||||
| Bare `/`, optionally with `workspaceId` | Remembered listing | Newest matching local task, otherwise remembered listing | Threads in that workspace |
|
||||
| Home action | Existing overview destination | Existing overview destination, never recent-task resume | `/threads?workspace=<id>` |
|
||||
| Explicit `/?home=overview` | Remembered listing | Remembered listing | Remembered listing, bypassing fixed default |
|
||||
| Explicit workflow root URL | Board for that workflow | Same | Same |
|
||||
| Explicit `/tasks`, `/threads`, task/session or focus URL | Keep destination | Keep destination | Keep destination |
|
||||
|
||||
An explicit overview selects the overview family, not a fixed Kanban mode.
|
||||
It already restores remembered List or Threads. Preserve that meaning. A
|
||||
view-toggle selection records its chosen mode before navigating through
|
||||
`resolveTaskListingNavigation`; the fixed default cannot bounce the user back
|
||||
to Threads when they deliberately select Kanban or Pipeline.
|
||||
|
||||
### Bare root
|
||||
|
||||
Keep startup policy in `apps/web/lib/startup-page.ts` and its integration in
|
||||
`apps/web/app/page-client.tsx`. Use the existing
|
||||
`isExplicitHomeDestination` checks for props and query task/session IDs,
|
||||
workflow scope, and `home=overview`. A workspace ID alone scopes Home and does
|
||||
not suppress startup selection.
|
||||
|
||||
After route settings and workspace resolution settle, a bare root with
|
||||
`startupPage === "threads"` resolves directly to `linkToThreads(workspaceId)`.
|
||||
Otherwise preserve `resolveStartupTaskId` and remembered-listing fallback.
|
||||
Use one resolved redirect in the effect, with `router.replace`, so a fixed
|
||||
choice cannot issue a competing remembered-list redirect in the same render.
|
||||
An explicit workflow or task/session destination suppresses both startup
|
||||
override and remembered routed-view restoration as it does today.
|
||||
|
||||
Hydration readiness belongs to the route/bootstrap boundary, not the local
|
||||
view hook. The `useTaskListingView` effect synchronizes `kanbanViewMode` while
|
||||
preserving `loaded`; it must not manufacture authoritative settings readiness.
|
||||
Preserve the actual loaded state and wait for the existing
|
||||
boot settings or completion of the client settings fetch before resolving a
|
||||
default. Do not use board snapshots or the presence of workflows alone as a
|
||||
readiness gate: an empty workspace must still reach Threads or onboarding.
|
||||
Return explicit readiness from `useKanbanRouteBootstrap`, scoped to the
|
||||
requested workspace and fetch lifecycle, and consume it in `KanbanRoute` before
|
||||
mounting the default-resolving page.
|
||||
Record completion for both already-hydrated boot state and fetched state.
|
||||
Completion remains settled for the same route selection while live workflow
|
||||
filters or board snapshots change; these updates belong to the mounted listing,
|
||||
not startup. A different requested workspace or workflow has its own readiness
|
||||
and cancellation lifecycle.
|
||||
Failed settings fetches complete with the normal fallback rather than waiting
|
||||
forever. A late response for a previous workspace cannot redirect the current
|
||||
route. Existing Office mismatch handling runs before this task-listing choice.
|
||||
|
||||
### Explicit destinations and reload
|
||||
|
||||
The default is not a global route guard. Do not attach a redirect to the
|
||||
always-mounted sidebar, `useTaskListingView`, or a settings subscription.
|
||||
`/tasks` stays List on reload even with a Threads default. `/threads` retains
|
||||
workspace, `taskId`, and `sessionId` focus parameters. Browser Back/Forward
|
||||
restores the explicit route. Settings changes update future Home hrefs without
|
||||
replacing the current task, workflow, settings, or listing route.
|
||||
|
||||
Keep `linkToTaskOverview`, task Back links, workflow navigation, and
|
||||
`listingHistoryHref` as explicit navigation. Never blanket-replace their
|
||||
overview URLs with a Home-default resolver.
|
||||
|
||||
## Home consumers
|
||||
|
||||
Use one pure Home resolver in
|
||||
`apps/web/lib/navigation/workspace-home.ts` with workspace ID, Office mode,
|
||||
and the saved startup choice as inputs. Both `workspaceHomeHref` and
|
||||
`homeDestinationHref` in `core-destinations.ts` delegate to it. Extend
|
||||
`NavContext` and `useNavContext` to carry `startupPage` from authoritative
|
||||
settings. A missing choice defaults to `task_overview` for existing callers.
|
||||
|
||||
Resolution order is Office home, explicit Threads home for a resolved
|
||||
non-Office workspace, then the existing overview. Preserve encoded IDs and
|
||||
the existing different query names (`workspaceId` for overview/Office,
|
||||
`workspace` for Threads). Unknown workspace mode retains the current disabled
|
||||
Home affordance. No-workspace onboarding remains available.
|
||||
|
||||
Office priority uses effective mode, not workspace metadata alone. Phone
|
||||
listing menus use `AppNavSections` and `useNavContext`, whose `useInOffice`
|
||||
input follows `useOfficeModeState` and the Office feature gate. With Office
|
||||
disabled, the shared resolver preserves the workspace and saved task-listing
|
||||
default without linking to the unavailable Office surface.
|
||||
|
||||
Audit and wire these callers; do not infer workspace type from the pathname:
|
||||
|
||||
- Sidebar brand/header and primary Home row.
|
||||
- Sidebar settings-exit action and workspace picker, including picking the
|
||||
active workspace again when that action currently navigates Home.
|
||||
- `useHomeAffordance`, used by shared topbars and mobile navigation.
|
||||
- The phone listing drawer's Home row in `AppNavSections`. Reuse the shared
|
||||
compact header and menu composition; only Home destination/state changes.
|
||||
- Manifest mobile Home and palette `nav-home`. The palette retains its
|
||||
workspace-less overview override for existing startup choices, but routes
|
||||
a selected Threads default through the same
|
||||
workspace-aware Home resolver. Office remains Office when applying the
|
||||
Threads choice; do not send it into a deck.
|
||||
|
||||
Changes in `src/kanban-route.tsx` are limited to entry readiness or wiring;
|
||||
its Office mismatch redirect is not a new Home action. Office recovery links
|
||||
and explicit task overview/Back links retain their existing behavior.
|
||||
|
||||
## Remembered listing and list details
|
||||
|
||||
`view-preference.ts` remains responsible for parsing the local JSON enum,
|
||||
legacy `kanban_view_mode === "graph2"` fallback only when no local value
|
||||
exists, and same-document/storage notifications. A present invalid local
|
||||
value returns Kanban rather than using the legacy fallback. Failed writes keep
|
||||
the chosen mode transiently for the current document.
|
||||
|
||||
`use-task-listing-view.ts` exposes the preferred/effective modes and updates
|
||||
the in-memory Kanban rendering mode. Pipeline renders as Kanban on phones
|
||||
without changing stored Pipeline; Threads uses the native phone deck.
|
||||
`view-navigation.ts` owns explicit toggle routes and path-preserving scope
|
||||
changes. None of these boundaries owns the portable Home choice.
|
||||
|
||||
Keep the existing workflow resolver's All Workflows behavior and List's
|
||||
`tasksListShowDetails` flow through `useKanbanDisplaySettings`, display
|
||||
controls, and `tasks-list-view.tsx`. Details remain false by default, portable,
|
||||
and independent of Home selection. Missing metadata omits only that metadata;
|
||||
secondary row actions do not activate the row. No rich-row implementation
|
||||
change is required for Threads Home.
|
||||
|
||||
## Settings and mobile composition
|
||||
|
||||
`StartupPageSettingsCard` includes `threads` in its radio options. Keep
|
||||
`AppearanceState.startupPage`, `buildAppearanceUserSettingsPatch`, draft rebase,
|
||||
and the `SettingsSaveProvider` contributor. Add no page-local Save button and
|
||||
do not preview the destination by navigating while the user edits the form.
|
||||
|
||||
Revise the existing localized descriptions so they accurately explain all
|
||||
three options: Task overview restores the last-used listing, Last visited task
|
||||
only resumes on startup, and Threads always opens on startup and Home in task
|
||||
workspaces. The shared description must no longer claim every Home action
|
||||
always opens overview. Reuse the existing Threads label key where possible;
|
||||
include Threads/home-default search terms in Startup Page discovery aliases.
|
||||
Update English, pt-pt, and zh-cn, generate zh-hk/zh-tw with `i18n:zh-hant`, and
|
||||
regenerate pseudo. Resolve copy with `t()` at render time; no em dashes.
|
||||
|
||||
Mobile contract:
|
||||
|
||||
- Entry is the existing phone Settings navigation into Appearance. The nearest
|
||||
shipped form is `startup-page-settings-card.tsx`, with full-width labelled
|
||||
radio rows. The shared phone menu follows `mobile-menu-sheet.tsx`'s inset
|
||||
drawer, fixed heading, internal scroll, and safe-area treatment.
|
||||
- The persistent preference stays inline in the full-page Settings form.
|
||||
This short single choice needs no extra picker overlay. Option label comes
|
||||
first, its effect second; the shared floating Save changes action commits it.
|
||||
- Reuse the Settings page's existing vertical scroll owner and safe-area
|
||||
clearance. Labels wrap; phone hit areas are at least 44px high. Preserve
|
||||
desktop density and current breakpoints; do not normalize topbar geometry.
|
||||
- After navigation, reuse the native single-column Threads deck. Do not add a
|
||||
mobile fallback or change deck/session loading solely because it is Home.
|
||||
- Settings draft, persistence, routing, and failure semantics are shared
|
||||
across desktop and phone; only established surface composition differs.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
Unsupported stored startup strings normalize to overview; unsupported writes
|
||||
are rejected. Save errors leave the prior authoritative default and a
|
||||
retryable Settings draft. A reload receives the backend value, never a retry
|
||||
marker from browser storage. A successful save or live update changes future
|
||||
Home navigation through the existing revision-aware settings store.
|
||||
|
||||
Blocked local storage cannot prevent fixed Threads navigation. An empty deck
|
||||
is a valid destination and keeps its empty state. Failure to find a recent
|
||||
task uses the remembered-listing fallback only for `last_task`. No-workspace
|
||||
and failed-bootstrap paths retain the existing recovery surface and must not
|
||||
loop or permanently display a startup loader.
|
||||
|
||||
Existing user authorization and workspace access checks remain unchanged;
|
||||
startup choice conveys no additional access. Existing settings request/event
|
||||
errors and route tests provide evidence; no new logs, metrics, or feature flag
|
||||
are required.
|
||||
|
||||
## Verification
|
||||
|
||||
Backend tests cover enum normalization, rejected/omitted patches, JSON
|
||||
round-trip, user isolation, DTO/event/boot retention, and reopening persisted
|
||||
settings. Frontend tests cover parser/mapper/WS parity, shared Home hrefs,
|
||||
explicit-destination precedence, deferred bootstrap, and independent local
|
||||
view memory. Keep existing last-task and Pipeline fallback regressions.
|
||||
|
||||
Extend `e2e/tests/settings/startup-page.spec.ts` and
|
||||
`mobile-startup-page.spec.ts` for selection, Save/discard/failure, reload,
|
||||
Home after using List, workspace switching, and protected explicit routes.
|
||||
Use isolated test fixtures. Phone proof uses actual taps, checks the labelled
|
||||
row hit area, verifies reachable Save, and checks no horizontal overflow.
|
||||
The [implementation plan](../../../plans/threads-home-default/plan.md)
|
||||
maps acceptance criteria to exact tests and commands.
|
||||
Reference in New Issue
Block a user