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);
+ });
+});