fix(office): isolate kanban and office workspace selection (#1019)

This commit is contained in:
Carlos Florêncio
2026-05-22 22:10:11 +01:00
committed by GitHub
parent 5c2f595785
commit 7407204fe1
11 changed files with 498 additions and 32 deletions
@@ -25,7 +25,7 @@ func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, er
if err != nil {
return nil, err
}
result := toAgentDTO(agent, profiles)
result := toAgentDTO(agent, filterGlobalProfiles(profiles))
c.applyCapabilityStatus(&result, agent.Name)
c.applyBillingType(&result, agent.Name)
return &result, nil
@@ -42,7 +42,7 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e
if err != nil {
return nil, err
}
entry := toAgentDTO(agent, profiles)
entry := toAgentDTO(agent, filterGlobalProfiles(profiles))
c.applyCapabilityStatus(&entry, agent.Name)
c.applyBillingType(&entry, agent.Name)
payload = append(payload, entry)
@@ -50,6 +50,21 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e
return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil
}
// filterGlobalProfiles drops workspace-scoped (office) rows from a profile
// list so the kanban-facing GET /api/v1/agents endpoints only surface global
// CLI profiles. Office agents live in the same agent_profiles table since
// ADR 0005 Wave G but are scoped by workspace_id; the kanban task picker
// must not show them.
func filterGlobalProfiles(profiles []*models.AgentProfile) []*models.AgentProfile {
out := make([]*models.AgentProfile, 0, len(profiles))
for _, p := range profiles {
if p.WorkspaceID == "" {
out = append(out, p)
}
}
return out
}
type CreateAgentRequest struct {
Name string
WorkspaceID *string
@@ -209,7 +224,7 @@ func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*
if err != nil {
return nil, err
}
result := toAgentDTO(agent, profiles)
result := toAgentDTO(agent, filterGlobalProfiles(profiles))
return &result, nil
}
@@ -0,0 +1,107 @@
package controller
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
// TestListAgents_ExcludesWorkspaceScopedProfiles regresses the bug where the
// kanban task-create agent picker listed office-managed agent rows (e.g.
// "Claude / CEO"). After ADR 0005 Wave G office AgentInstance and kanban
// AgentProfile share the agent_profiles table — kanban-facing reads must
// strip rows with a non-empty workspace_id.
func TestListAgents_ExcludesWorkspaceScopedProfiles(t *testing.T) {
st := newFakeStore()
st.nextAgentID = 1
agent := &models.Agent{ID: "agent-1", Name: "claude-acp"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
st.profiles[agent.ID] = []*models.AgentProfile{
{ID: "p-global", AgentID: agent.ID, Name: "Sonnet", WorkspaceID: ""},
{ID: "p-office", AgentID: agent.ID, Name: "CEO", WorkspaceID: "ws-1"},
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
ctrl := &Controller{
repo: st,
agentRegistry: registry.NewRegistry(log),
logger: log,
}
resp, err := ctrl.ListAgents(context.Background())
if err != nil {
t.Fatalf("ListAgents: %v", err)
}
if len(resp.Agents) != 1 {
t.Fatalf("expected 1 agent, got %d", len(resp.Agents))
}
got := resp.Agents[0]
if len(got.Profiles) != 1 {
t.Fatalf("expected 1 profile after filtering office row, got %d: %+v", len(got.Profiles), got.Profiles)
}
if got.Profiles[0].ID != "p-global" {
t.Errorf("expected p-global, got %q", got.Profiles[0].ID)
}
}
func TestGetAgent_ExcludesWorkspaceScopedProfiles(t *testing.T) {
st := newFakeStore()
agent := &models.Agent{ID: "agent-1", Name: "claude-acp"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
st.profiles[agent.ID] = []*models.AgentProfile{
{ID: "p-global", AgentID: agent.ID, Name: "Sonnet", WorkspaceID: ""},
{ID: "p-office", AgentID: agent.ID, Name: "CEO", WorkspaceID: "ws-1"},
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
ctrl := &Controller{
repo: st,
agentRegistry: registry.NewRegistry(log),
logger: log,
}
got, err := ctrl.GetAgent(context.Background(), "agent-1")
if err != nil {
t.Fatalf("GetAgent: %v", err)
}
if len(got.Profiles) != 1 {
t.Fatalf("expected 1 profile after filtering office row, got %d", len(got.Profiles))
}
if got.Profiles[0].ID != "p-global" {
t.Errorf("expected p-global, got %q", got.Profiles[0].ID)
}
}
func TestUpdateAgent_ExcludesWorkspaceScopedProfiles(t *testing.T) {
st := newFakeStore()
agent := &models.Agent{ID: "agent-1", Name: "claude-acp"}
st.agents[agent.ID] = agent
st.byName[agent.Name] = agent
st.profiles[agent.ID] = []*models.AgentProfile{
{ID: "p-global", AgentID: agent.ID, Name: "Sonnet", WorkspaceID: ""},
{ID: "p-office", AgentID: agent.ID, Name: "CEO", WorkspaceID: "ws-1"},
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
ctrl := &Controller{
repo: st,
agentRegistry: registry.NewRegistry(log),
logger: log,
}
got, err := ctrl.UpdateAgent(context.Background(), UpdateAgentRequest{ID: "agent-1"})
if err != nil {
t.Fatalf("UpdateAgent: %v", err)
}
if len(got.Profiles) != 1 {
t.Fatalf("expected 1 profile after filtering office row, got %d", len(got.Profiles))
}
if got.Profiles[0].ID != "p-global" {
t.Errorf("expected p-global, got %q", got.Profiles[0].ID)
}
}
@@ -1,6 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { IconPlus, IconArrowLeft } from "@tabler/icons-react";
import Link from "next/link";
import { Button } from "@kandev/ui/button";
@@ -56,10 +57,14 @@ export function WorkspaceRail({
const items = storeWorkspaces.items.length > 0 ? storeWorkspaces.items : ssrWorkspaces;
const activeId = storeWorkspaces.activeId ?? ssrActiveId;
const handleSelect = (id: string) => {
setActiveWorkspace(id);
router.push(`/office?workspaceId=${id}`);
};
const handleSelect = useCallback(
(id: string) => {
document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`;
setActiveWorkspace(id);
router.push(`/office?workspaceId=${id}`);
},
[setActiveWorkspace, router],
);
return (
<div className="w-[60px] h-full border-r border-border bg-background flex flex-col items-center py-3 shrink-0">
+25 -3
View File
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateHydrator } from "@/components/state-hydrator";
import { getFeatureFlagsAction } from "@/app/actions/features";
@@ -16,6 +17,19 @@ import { WorkspaceRail } from "./components/workspace-rail";
import { OfficeSidebar } from "./components/office-sidebar";
import { OfficeTopbar } from "./components/office-topbar";
function resolveActiveOfficeWorkspaceId(
workspaceItems: { id: string }[],
cookieWorkspaceId: string | null,
settingsWorkspaceId: string | null,
): string | null {
return (
workspaceItems.find((w) => w.id === cookieWorkspaceId)?.id ??
workspaceItems.find((w) => w.id === settingsWorkspaceId)?.id ??
workspaceItems[0]?.id ??
null
);
}
function mapWorkspaceItem(ws: {
id: string;
name: string;
@@ -64,10 +78,11 @@ export default async function OfficeLayout({ children }: { children: React.React
return <>{children}</>;
}
const [workspacesResponse, userSettingsResponse, metaResponse] = await Promise.all([
const [workspacesResponse, userSettingsResponse, metaResponse, cookieStore] = await Promise.all([
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
getMeta({ cache: "no-store" }).catch(() => null),
cookies().catch(() => null),
]);
// Only show office workspaces (those with an office_workflow_id) in the rail.
@@ -75,8 +90,15 @@ export default async function OfficeLayout({ children }: { children: React.React
const officeWorkspaces = workspacesResponse.workspaces.filter((ws) => ws.office_workflow_id);
const workspaceItems = officeWorkspaces.map(mapWorkspaceItem);
const settingsWorkspaceId = userSettingsResponse?.settings?.workspace_id || null;
const activeWorkspaceId =
workspaceItems.find((w) => w.id === settingsWorkspaceId)?.id ?? workspaceItems[0]?.id ?? null;
// office-active-workspace cookie is set by the setup wizard and workspace rail so the
// layout can load the correct workspace even when userSettings.workspace_id still points
// to a kanban workspace (we never write to userSettings from office).
const cookieWorkspaceId = cookieStore?.get("office-active-workspace")?.value ?? null;
const activeWorkspaceId = resolveActiveOfficeWorkspaceId(
workspaceItems,
cookieWorkspaceId,
settingsWorkspaceId,
);
// Fetch agents + projects + inbox for the active workspace so the
// sidebar renders them — including the inbox count badge — on first
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
listWorkspaces: vi.fn(),
fetchUserSettings: vi.fn(),
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
updateUserSettings: vi.fn(),
}));
import { cookies } from "next/headers";
import { listWorkspaces, fetchUserSettings } from "@/lib/api";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import { getActiveWorkspaceId } from "./get-active-workspace";
const mockCookies = vi.mocked(cookies);
const mockListWorkspaces = vi.mocked(listWorkspaces);
const mockFetchUserSettings = vi.mocked(fetchUserSettings);
const mockUpdateUserSettings = vi.mocked(updateUserSettings);
const OFFICE_WS_ID = "ws-office-1";
const OFFICE_WS_ID_2 = "ws-office-2";
const KANBAN_WS_ID = "ws-kanban-1";
const OFFICE_WS = { id: OFFICE_WS_ID, name: "Office", office_workflow_id: "wf-1" };
const OFFICE_WS_2 = { id: OFFICE_WS_ID_2, name: "Office 2", office_workflow_id: "wf-2" };
const KANBAN_WS = { id: KANBAN_WS_ID, name: "Kanban" }; // no office_workflow_id
function makeSettings(workspaceId: string) {
return { settings: { workspace_id: workspaceId } };
}
function makeCookieStore(workspaceId: string | null) {
return {
get: (name: string) =>
name === "office-active-workspace" && workspaceId ? { value: workspaceId } : undefined,
};
}
beforeEach(() => {
mockCookies.mockReset();
mockListWorkspaces.mockReset();
mockFetchUserSettings.mockReset();
mockUpdateUserSettings.mockReset();
mockUpdateUserSettings.mockResolvedValue({} as never);
// Default: no cookie set
mockCookies.mockResolvedValue(makeCookieStore(null) as never);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getActiveWorkspaceId", () => {
describe("URL workspaceId arg wins when it matches an office workspace", () => {
it("returns the URL workspace id and does NOT call updateUserSettings", async () => {
mockListWorkspaces.mockResolvedValue({ workspaces: [OFFICE_WS] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings("ws-other-1") as never);
const result = await getActiveWorkspaceId(OFFICE_WS_ID);
expect(result).toBe(OFFICE_WS_ID);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
it("ignores the URL param when it does not match any office workspace", async () => {
mockListWorkspaces.mockResolvedValue({ workspaces: [OFFICE_WS] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(OFFICE_WS_ID) as never);
// "ws-unknown" not in office list - falls back to settings match
const result = await getActiveWorkspaceId("ws-unknown-1");
expect(result).toBe(OFFICE_WS_ID);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
describe("cookie wins after URL param misses", () => {
it("returns the cookie workspace and does NOT call updateUserSettings", async () => {
mockCookies.mockResolvedValue(makeCookieStore(OFFICE_WS_ID_2) as never);
mockListWorkspaces.mockResolvedValue({ workspaces: [OFFICE_WS, OFFICE_WS_2] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(KANBAN_WS_ID) as never);
const result = await getActiveWorkspaceId();
expect(result).toBe(OFFICE_WS_ID_2);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
it("ignores cookie when it does not match any office workspace", async () => {
mockCookies.mockResolvedValue(makeCookieStore("ws-stale") as never);
mockListWorkspaces.mockResolvedValue({ workspaces: [OFFICE_WS] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(OFFICE_WS_ID) as never);
const result = await getActiveWorkspaceId();
expect(result).toBe(OFFICE_WS_ID);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
describe("regression: kanban workspace in settings must not trigger updateUserSettings", () => {
it("falls back to first office workspace and does NOT call updateUserSettings", async () => {
mockListWorkspaces.mockResolvedValue({ workspaces: [KANBAN_WS, OFFICE_WS] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(KANBAN_WS_ID) as never);
const result = await getActiveWorkspaceId();
expect(result).toBe(OFFICE_WS_ID);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
describe("settings.workspace_id points to a valid office workspace", () => {
it("returns the settings workspace id and does NOT call updateUserSettings", async () => {
mockListWorkspaces.mockResolvedValue({ workspaces: [OFFICE_WS] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(OFFICE_WS_ID) as never);
const result = await getActiveWorkspaceId();
expect(result).toBe(OFFICE_WS_ID);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
describe("no office workspaces exist", () => {
it("returns null and does not call updateUserSettings", async () => {
mockListWorkspaces.mockResolvedValue({ workspaces: [] } as never);
mockFetchUserSettings.mockResolvedValue(makeSettings(OFFICE_WS_ID) as never);
const result = await getActiveWorkspaceId();
expect(result).toBeNull();
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
});
});
+31 -13
View File
@@ -1,34 +1,52 @@
import { cookies } from "next/headers";
import { listWorkspaces, fetchUserSettings } from "@/lib/api";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
/**
* Server-side helper to resolve the active workspace ID.
* Re-fetches workspaces and user settings (Next.js will deduplicate
* these calls within the same request when the layout also fetches them).
*
* If the saved workspace ID no longer exists (e.g. workspace was deleted),
* falls back to the first available workspace and persists the correction
* so subsequent requests don't hit the stale ID.
* Priority order:
* 1. urlWorkspaceId when it matches a valid office workspace.
* 2. office-active-workspace cookie when it matches a valid office workspace.
* 3. userSettings.workspace_id when it matches a valid office workspace.
* 4. First available office workspace as fallback.
*
* Does NOT write to user settings - the caller must not pollute the shared
* workspace_id that kanban uses.
*/
export async function getActiveWorkspaceId(): Promise<string | null> {
const [workspacesRes, settingsRes] = await Promise.all([
export async function getActiveWorkspaceId(urlWorkspaceId?: string): Promise<string | null> {
const [workspacesRes, settingsRes, cookieStore] = await Promise.all([
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
cookies().catch(() => null),
]);
// Only consider office workspaces (those with office_workflow_id set).
const workspaces = workspacesRes.workspaces.filter((w) => w.office_workflow_id);
// 1. URL param wins when it matches a valid office workspace.
if (urlWorkspaceId) {
const urlMatch = workspaces.find((w) => w.id === urlWorkspaceId);
if (urlMatch) return urlMatch.id;
}
// 2. Cookie set by the setup wizard and workspace rail - keeps page and
// layout in sync since layouts cannot read URL search params.
const cookieWorkspaceId = cookieStore?.get("office-active-workspace")?.value;
if (cookieWorkspaceId) {
const cookieMatch = workspaces.find((w) => w.id === cookieWorkspaceId);
if (cookieMatch) return cookieMatch.id;
}
// 3. Check if the settings workspace_id points to a valid office workspace.
const settingsWorkspaceId = settingsRes?.settings?.workspace_id || null;
const matched = workspaces.find((w) => w.id === settingsWorkspaceId);
if (matched) {
return matched.id;
}
// Settings point to a workspace that no longer exists — fall back and persist.
const fallbackId = workspaces[0]?.id ?? null;
if (fallbackId && settingsWorkspaceId && fallbackId !== settingsWorkspaceId) {
updateUserSettings({ workspace_id: fallbackId }).catch(() => {});
}
return fallbackId;
// 4. Fall back to the first available office workspace.
// Do NOT persist this - userSettings.workspace_id belongs to kanban.
return workspaces[0]?.id ?? null;
}
+5 -2
View File
@@ -4,7 +4,9 @@ import { getActiveWorkspaceId } from "./lib/get-active-workspace";
import { OfficePageClient } from "./page-client";
import type { DashboardData } from "@/lib/state/slices/office/types";
export default async function OfficePage() {
type SearchParams = Promise<{ workspaceId?: string }>;
export default async function OfficePage({ searchParams }: { searchParams?: SearchParams }) {
const onboarding = await getOnboardingState({ cache: "no-store" }).catch(() => ({
completed: true,
}));
@@ -12,7 +14,8 @@ export default async function OfficePage() {
redirect("/office/setup");
}
const workspaceId = await getActiveWorkspaceId();
const params = searchParams ? await searchParams : {};
const workspaceId = await getActiveWorkspaceId(params.workspaceId);
let dashboard: DashboardData | null = null;
if (workspaceId) {
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api/domains/office-api", () => ({
completeOnboarding: vi.fn(),
importFromFS: vi.fn(),
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
updateUserSettings: vi.fn(),
}));
import { completeOnboarding } from "@/lib/api/domains/office-api";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import { submitOnboarding } from "./setup-wizard";
const mockCompleteOnboarding = vi.mocked(completeOnboarding);
const mockUpdateUserSettings = vi.mocked(updateUserSettings);
const WORKSPACE_NAME = "My Workspace";
const NEW_WORKSPACE_ID = "ws-new-1";
const BASE_WIZARD_DATA = {
workspaceName: WORKSPACE_NAME,
taskPrefix: "MY",
agentName: "CEO",
agentProfileId: "profile-1",
executorPreference: "local_pc",
taskTitle: "",
taskDescription: "",
} as const;
beforeEach(() => {
mockCompleteOnboarding.mockReset();
mockUpdateUserSettings.mockReset();
mockCompleteOnboarding.mockResolvedValue({
workspaceId: NEW_WORKSPACE_ID,
agentId: "agent-1",
projectId: "proj-1",
} as never);
});
describe("submitOnboarding", () => {
it("calls completeOnboarding with the wizard data", async () => {
await submitOnboarding(BASE_WIZARD_DATA);
expect(mockCompleteOnboarding).toHaveBeenCalledOnce();
expect(mockCompleteOnboarding).toHaveBeenCalledWith(
expect.objectContaining({ workspaceName: WORKSPACE_NAME }),
);
});
it("does NOT call updateUserSettings after completing onboarding", async () => {
await submitOnboarding(BASE_WIZARD_DATA);
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
});
it("returns the result from completeOnboarding", async () => {
const result = await submitOnboarding(BASE_WIZARD_DATA);
expect(result.workspaceId).toBe(NEW_WORKSPACE_ID);
});
});
+4 -5
View File
@@ -16,7 +16,6 @@ import { StepReview } from "./step-review";
import { WizardFooter } from "./wizard-footer";
import { CloseButton } from "./close-button";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import type { Tier } from "@/lib/state/slices/office/types";
type SetupWizardProps = {
@@ -78,7 +77,7 @@ function dotColor(index: number, current: number): string {
return "bg-muted";
}
async function submitOnboarding(data: WizardData) {
export async function submitOnboarding(data: WizardData) {
const result = await completeOnboarding({
workspaceName: data.workspaceName.trim() || "default",
taskPrefix: data.taskPrefix.trim() || "KAN",
@@ -89,7 +88,6 @@ async function submitOnboarding(data: WizardData) {
taskDescription: data.taskDescription.trim() || undefined,
default_tier: data.defaultTier,
});
await updateUserSettings({ workspace_id: result.workspaceId });
return result;
}
@@ -174,9 +172,10 @@ export function SetupWizard({
const handleSubmit = useCallback(async () => {
setSubmitting(true);
try {
await submitOnboarding(data);
const result = await submitOnboarding(data);
toast.success("Workspace created successfully");
router.push("/office");
document.cookie = `office-active-workspace=${result.workspaceId}; path=/; max-age=86400; samesite=strict; secure`;
router.push(`/office?workspaceId=${result.workspaceId}`);
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to complete setup");
+2 -2
View File
@@ -154,7 +154,7 @@ test.describe("Onboarding", () => {
const agentId = (completed as { agentId?: string }).agentId;
expect(agentId).toBeTruthy();
await expect(testPage).toHaveURL(/\/office$/, { timeout: 15_000 });
await expect(testPage).toHaveURL(/\/office(\?|$)/, { timeout: 15_000 });
await testPage.goto(`/office/agents/${agentId}/configuration`);
await expect(testPage.getByTestId("cli-config-card")).toBeVisible();
await expect(testPage.getByTestId("cli-config-card").getByText("Unassigned")).toHaveCount(0);
@@ -213,7 +213,7 @@ test.describe("Onboarding", () => {
await responsePromise;
// Redirect to dashboard with new workspace selected
await expect(testPage).toHaveURL(/\/office$/, { timeout: 15_000 });
await expect(testPage).toHaveURL(/\/office(\?|$)/, { timeout: 15_000 });
await expect(testPage.getByRole("heading", { name: /Dashboard/i }).first()).toBeVisible({
timeout: 10_000,
});
@@ -0,0 +1,93 @@
/**
* Regression: completing the office setup wizard must NOT overwrite the shared
* userSettings.workspace_id that kanban uses to remember the active workspace.
*/
import { test, expect } from "../../fixtures/office-fixture";
test.describe("Workspace selection isolation", () => {
test("wizard completion does not overwrite kanban workspace in user settings", async ({
testPage,
apiClient,
seedData,
officeSeed: _,
}) => {
test.setTimeout(90_000);
// Point user settings at the kanban workspace (not the office one).
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
keyboard_shortcuts: {},
enable_preview_on_click: false,
sidebar_views: [],
});
// Navigate to the "add workspace" wizard.
await testPage.goto("/office/setup?mode=new");
await expect(
testPage.getByRole("heading", { name: "Set up your Office workspace" }),
).toBeVisible({ timeout: 10_000 });
// Step 1: fill in workspace name.
await testPage.getByLabel("Workspace name").fill("Isolation Test Workspace");
await testPage.getByRole("button", { name: /next/i }).click();
// Step 2: agent config - advance without changing profile selection.
await expect(
testPage.getByRole("heading", { name: "Create your coordinator agent" }),
).toBeVisible({ timeout: 10_000 });
await testPage.getByRole("button", { name: /next/i }).click();
// Step 3: task - skip.
await expect(
testPage.getByRole("heading", { name: "Give your CEO something to do" }),
).toBeVisible({ timeout: 10_000 });
await testPage.getByRole("button", { name: /skip/i }).click();
// Step 4: review and submit.
await expect(testPage.getByRole("heading", { name: "Review and launch" })).toBeVisible({
timeout: 10_000,
});
await expect(testPage.getByText("Isolation Test Workspace")).toBeVisible();
const responsePromise = testPage.waitForResponse(
(resp) => resp.url().includes("/onboarding/complete") && resp.status() === 201,
{ timeout: 15_000 },
);
await testPage.locator("button:has-text('Create & Launch')").click();
await responsePromise;
// Wizard redirects to /office (or /office?workspaceId=...) after completion.
await expect(testPage).toHaveURL(/\/office(\?|$)/, { timeout: 15_000 });
// The critical assertion: user settings workspace_id must still point to
// the kanban workspace - not the newly created office workspace.
const settings = await apiClient.getUserSettings();
expect(settings.settings.workspace_id).toBe(seedData.workspaceId);
});
test("office page with workspaceId URL param shows correct workspace without writing settings", async ({
testPage,
apiClient,
seedData,
officeSeed,
}) => {
// Point user settings at kanban workspace.
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
keyboard_shortcuts: {},
enable_preview_on_click: false,
sidebar_views: [],
});
// Navigate to /office with an explicit workspaceId param.
await testPage.goto(`/office?workspaceId=${officeSeed.workspaceId}`);
await expect(testPage).toHaveURL(/\/office(\?|$)/, { timeout: 10_000 });
await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 });
// User settings workspace_id must remain the kanban workspace.
const settings = await apiClient.getUserSettings();
expect(settings.settings.workspace_id).toBe(seedData.workspaceId);
});
});