diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 2049f36810..757e6a6e9f 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -633,6 +633,78 @@ func TestBootRouteDataIntegrationRouteUsesActiveWorkspaceCookie(t *testing.T) { } } +func TestBootRouteDataIntegrationRouteCookieWinsOverSettingsWorkspace(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) == 0 { + t.Fatal("expected seeded default workspace") + } + settingsWorkspaceID := workspaces[0].ID + cookieWorkspace, err := harness.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ + Name: "Cookie Workspace", + }) + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if _, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: cookieWorkspace.ID, + Name: "Cookie Workflow", + }); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if _, err := harness.userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ + WorkspaceID: &settingsWorkspaceID, + }); err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Fgithub", nil) + req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: cookieWorkspace.ID}) + payload := bootPayload(ctx, req, routeParams{ + taskSvc: harness.taskSvc, + userCtrl: harness.userCtrl, + services: &Services{ + Workflow: harness.workflowSvc, + }, + }, webapp.ClassifyRoute("/github")) + + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal payload: %v", err) + } + var decoded struct { + InitialState struct { + Workspaces struct { + ActiveID string `json:"activeId"` + } `json:"workspaces"` + UserSettings struct { + WorkspaceID string `json:"workspaceId"` + } `json:"userSettings"` + } `json:"initialState"` + RouteData struct { + RouteContext struct { + ActiveWorkspaceID string `json:"activeWorkspaceId"` + } `json:"routeContext"` + } `json:"routeData"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal payload: %v", err) + } + if decoded.InitialState.Workspaces.ActiveID != cookieWorkspace.ID { + t.Fatalf("initial active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, cookieWorkspace.ID) + } + if decoded.InitialState.UserSettings.WorkspaceID != cookieWorkspace.ID { + t.Fatalf("user settings workspace = %q, want %q", decoded.InitialState.UserSettings.WorkspaceID, cookieWorkspace.ID) + } + if decoded.RouteData.RouteContext.ActiveWorkspaceID != cookieWorkspace.ID { + t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.RouteContext.ActiveWorkspaceID, cookieWorkspace.ID) + } +} + func nonFallbackWorkspace(t *testing.T, ctx context.Context, taskSvc *taskservice.Service) *models.Workspace { t.Helper() workspaces, err := taskSvc.ListWorkspaces(ctx) diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index 2f9bd5cc4c..8e267fb57e 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -367,16 +367,28 @@ function useRouteData({ async function bootstrap() { const [workspacesResponse, settingsResponse] = await Promise.all([ - listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + listWorkspaces({ cache: "no-store" }).catch(() => null), fetchUserSettings({ cache: "no-store" }).catch(() => null), ]); if (cancelled) return; - const workspaces = workspacesResponse.workspaces; - const workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id || null; + const settingsWorkspaceId = settingsResponse?.settings?.workspace_id || null; const settingsWorkflowId = settingsResponse?.settings?.workflow_filter_id || null; + const storeWorkspaceId = store.getState().workspaces.activeId; + const cookieWorkspaceId = readActiveWorkspaceCookie(); + const workspaceItems = + workspacesResponse?.workspaces.map(mapWorkspaceItem) ?? store.getState().workspaces.items; + const workspaceId = + workspaceItems.length > 0 + ? resolveActiveId( + workspaceItems, + storeWorkspaceId, + cookieWorkspaceId, + settingsWorkspaceId, + ) + : firstKnownWorkspaceId(storeWorkspaceId, cookieWorkspaceId, settingsWorkspaceId); store.getState().hydrate({ - workspaces: { items: workspaces, activeId: workspaceId }, + workspaces: { items: workspaceItems, activeId: workspaceId }, workflows: { items: store.getState().workflows.items, activeId: settingsWorkflowId }, userSettings: { ...mapUserSettingsResponse(settingsResponse), workspaceId }, }); @@ -422,6 +434,14 @@ function listWorkspaceWorkflowSteps(workspaceId: string) { }); } +function firstKnownWorkspaceId(...ids: (string | null | undefined)[]): string | null { + for (const id of ids) { + const value = id?.trim(); + if (value) return value; + } + return null; +} + function mapWorkflowItem(workflow: Workflow) { return { id: workflow.id, diff --git a/apps/web/src/spa-routes.workspace.test.tsx b/apps/web/src/spa-routes.workspace.test.tsx new file mode 100644 index 0000000000..dd5490a056 --- /dev/null +++ b/apps/web/src/spa-routes.workspace.test.tsx @@ -0,0 +1,162 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { StateProvider } from "@/components/state-provider"; +import { SpaRoutes } from "./spa-routes"; + +const mocks = vi.hoisted(() => ({ + listWorkspaces: vi.fn(), + listRepositories: vi.fn(), + listWorkflows: vi.fn(), + fetchUserSettings: vi.fn(), + fetchJson: vi.fn(), +})); + +const DEFAULT_WORKSPACE_ID = "ws-default"; +const SELECTED_WORKSPACE_ID = "ws-selected"; +const TEST_TIMESTAMP = "2026-06-24T00:00:00Z"; + +vi.mock("@/app/github/github-page-client", () => ({ + GitHubPageClient: ({ workspaceId }: { workspaceId?: string }) => ( +
+ ), +})); + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listWorkspaces: mocks.listWorkspaces, + listRepositories: mocks.listRepositories, +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + listWorkflows: mocks.listWorkflows, +})); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: mocks.fetchUserSettings, +})); + +vi.mock("@/lib/api/client", () => ({ + fetchJson: mocks.fetchJson, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + document.cookie = "kandev-active-workspace=; path=/; max-age=0"; + document.cookie = "office-active-workspace=; path=/; max-age=0"; + window.history.replaceState({}, "", "/"); +}); + +describe("SpaRoutes data-backed workspace context", () => { + it("keeps the currently active workspace when opening GitHub from another workspace", async () => { + mockGitHubWorkspaceBootstrap(); + + render( +