fix: preserve github workspace context (#1487)

This commit is contained in:
Carlos Florêncio
2026-06-24 22:57:50 +01:00
committed by GitHub
parent adcfea3257
commit af0faa7665
3 changed files with 258 additions and 4 deletions
@@ -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)
+24 -4
View File
@@ -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,
+162
View File
@@ -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 }) => (
<div data-testid="github-page" data-workspace-id={workspaceId ?? ""} />
),
}));
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(
<StateProvider
initialState={{
workspaces: {
items: [workspaceState(DEFAULT_WORKSPACE_ID), workspaceState(SELECTED_WORKSPACE_ID)],
activeId: SELECTED_WORKSPACE_ID,
},
}}
>
<SpaRoutes />
</StateProvider>,
);
await expectSelectedWorkspace();
});
it("uses the active workspace cookie when the store has no active workspace yet", async () => {
document.cookie = `kandev-active-workspace=${SELECTED_WORKSPACE_ID}; path=/`;
mockGitHubWorkspaceBootstrap();
render(
<StateProvider>
<SpaRoutes />
</StateProvider>,
);
await expectSelectedWorkspace();
});
it("keeps a known active workspace when the workspace list request fails", async () => {
document.cookie = `kandev-active-workspace=${SELECTED_WORKSPACE_ID}; path=/`;
mockGitHubWorkspaceBootstrap({ workspacesError: new Error("network down") });
render(
<StateProvider>
<SpaRoutes />
</StateProvider>,
);
await expectSelectedWorkspace();
});
});
function mockGitHubWorkspaceBootstrap({ workspacesError }: { workspacesError?: Error } = {}) {
window.history.replaceState({}, "", "/github");
if (workspacesError) {
mocks.listWorkspaces.mockRejectedValue(workspacesError);
} else {
mocks.listWorkspaces.mockResolvedValue({
workspaces: [workspace(DEFAULT_WORKSPACE_ID), workspace(SELECTED_WORKSPACE_ID)],
});
}
mocks.fetchUserSettings.mockResolvedValue({
settings: {
workspace_id: DEFAULT_WORKSPACE_ID,
workflow_filter_id: "",
repository_ids: [],
updated_at: TEST_TIMESTAMP,
},
});
mocks.listWorkflows.mockResolvedValue({
workflows: [workflow("wf-selected", SELECTED_WORKSPACE_ID)],
});
mocks.listRepositories.mockResolvedValue({ repositories: [] });
mocks.fetchJson.mockResolvedValue({ steps: [], total: 0 });
}
async function expectSelectedWorkspace() {
await waitFor(() => {
expect(screen.getByTestId("github-page").getAttribute("data-workspace-id")).toBe(
SELECTED_WORKSPACE_ID,
);
});
expect(mocks.listWorkflows).toHaveBeenCalledWith(SELECTED_WORKSPACE_ID, {
cache: "no-store",
});
}
function workspace(id: string) {
return {
id,
name: id,
description: null,
owner_id: "owner-1",
default_executor_id: null,
default_environment_id: null,
default_agent_profile_id: null,
default_config_agent_profile_id: null,
office_workflow_id: null,
created_at: TEST_TIMESTAMP,
updated_at: TEST_TIMESTAMP,
};
}
function workspaceState(id: string) {
return workspace(id);
}
function workflow(id: string, workspaceId: string) {
return {
id,
workspace_id: workspaceId,
name: id,
description: null,
sort_order: 0,
created_at: TEST_TIMESTAMP,
updated_at: TEST_TIMESTAMP,
};
}