fix(office): isolate kanban and office workspace selection (#1019)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user