diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 43939779f1..dbdee2e048 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -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 } diff --git a/apps/backend/internal/agent/settings/controller/agent_crud_filter_test.go b/apps/backend/internal/agent/settings/controller/agent_crud_filter_test.go new file mode 100644 index 0000000000..56000308cd --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_crud_filter_test.go @@ -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) + } +} diff --git a/apps/web/app/office/components/workspace-rail.tsx b/apps/web/app/office/components/workspace-rail.tsx index b32f584eca..fd8a0f6efc 100644 --- a/apps/web/app/office/components/workspace-rail.tsx +++ b/apps/web/app/office/components/workspace-rail.tsx @@ -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 (
diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index 91c4261b73..f618a2b28a 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -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 diff --git a/apps/web/app/office/lib/get-active-workspace.test.ts b/apps/web/app/office/lib/get-active-workspace.test.ts new file mode 100644 index 0000000000..2a37ebd953 --- /dev/null +++ b/apps/web/app/office/lib/get-active-workspace.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/web/app/office/lib/get-active-workspace.ts b/apps/web/app/office/lib/get-active-workspace.ts index 8a28056099..b01de35021 100644 --- a/apps/web/app/office/lib/get-active-workspace.ts +++ b/apps/web/app/office/lib/get-active-workspace.ts @@ -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 { - const [workspacesRes, settingsRes] = await Promise.all([ +export async function getActiveWorkspaceId(urlWorkspaceId?: string): Promise { + 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; } diff --git a/apps/web/app/office/page.tsx b/apps/web/app/office/page.tsx index f6b2687d29..f4cf275fab 100644 --- a/apps/web/app/office/page.tsx +++ b/apps/web/app/office/page.tsx @@ -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) { diff --git a/apps/web/app/office/setup/setup-wizard.test.ts b/apps/web/app/office/setup/setup-wizard.test.ts new file mode 100644 index 0000000000..627efa6fda --- /dev/null +++ b/apps/web/app/office/setup/setup-wizard.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/office/setup/setup-wizard.tsx b/apps/web/app/office/setup/setup-wizard.tsx index 88cb184202..9a1eb73782 100644 --- a/apps/web/app/office/setup/setup-wizard.tsx +++ b/apps/web/app/office/setup/setup-wizard.tsx @@ -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"); diff --git a/apps/web/e2e/tests/office/onboarding.spec.ts b/apps/web/e2e/tests/office/onboarding.spec.ts index cc2f6df70f..d59cc7ad92 100644 --- a/apps/web/e2e/tests/office/onboarding.spec.ts +++ b/apps/web/e2e/tests/office/onboarding.spec.ts @@ -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, }); diff --git a/apps/web/e2e/tests/office/workspace-selection-isolation.spec.ts b/apps/web/e2e/tests/office/workspace-selection-isolation.spec.ts new file mode 100644 index 0000000000..8ceaa34515 --- /dev/null +++ b/apps/web/e2e/tests/office/workspace-selection-isolation.spec.ts @@ -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); + }); +});