diff --git a/apps/packages/types/src/http.ts b/apps/packages/types/src/http.ts index ac356529d2..a31cc0f790 100644 --- a/apps/packages/types/src/http.ts +++ b/apps/packages/types/src/http.ts @@ -24,6 +24,7 @@ export type Workspace = { name: string; description?: string | null; owner_id: string; + office_workflow_id?: string | null; created_at: string; updated_at: string; }; diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index 0f1f1ec057..7d6849e655 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -37,6 +37,7 @@ function mapWorkspaceItem(ws: { default_environment_id?: string | null; default_agent_profile_id?: string | null; default_config_agent_profile_id?: string | null; + office_workflow_id?: string | null; created_at: string; updated_at: string; }) { @@ -49,6 +50,7 @@ function mapWorkspaceItem(ws: { default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, + office_workflow_id: ws.office_workflow_id ?? null, created_at: ws.created_at, updated_at: ws.updated_at, }; @@ -83,17 +85,19 @@ export default async function OfficeLayout({ children }: { children: React.React cookies().catch(() => null), ]); - // Only show office workspaces (those with an office_workflow_id) in the rail. - // The kanban's "Default Workspace" has no office_workflow_id and should not appear. - const officeWorkspaces = workspacesResponse.workspaces.filter((ws) => ws.office_workflow_id); - const workspaceItems = officeWorkspaces.map(mapWorkspaceItem); + // Only office workspaces can be active inside /office, but hydrate every + // workspace into the shared sidebar picker so users can switch back to Kanban. + const officeWorkspaceItems = workspacesResponse.workspaces + .filter((ws) => ws.office_workflow_id) + .map(mapWorkspaceItem); + const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem); const settingsWorkspaceId = userSettingsResponse?.settings?.workspace_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, + officeWorkspaceItems, cookieWorkspaceId, settingsWorkspaceId, ); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 4a0ca41727..7e8b839f0a 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -38,6 +38,7 @@ function mapWorkspaceItem(ws: WorkspaceItem) { default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, + office_workflow_id: ws.office_workflow_id ?? null, created_at: ws.created_at, updated_at: ws.updated_at, }; diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index eb1bb451cd..4ba50a99f7 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -35,11 +35,16 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({ const storeState = { features: { office: false }, workspaces: { - items: [{ id: "w1", name: "Default Workspace" }], + items: [ + { id: "w1", name: "Default Workspace", office_workflow_id: "" }, + { id: "w2", name: "Office Workspace", office_workflow_id: "wf-office" }, + ], activeId: "w1", }, setActiveWorkspace: vi.fn(), }; +const KANBAN_WORKSPACE_ITEM = "sidebar-workspace-item-w1"; +const OFFICE_WORKSPACE_ITEM = "sidebar-workspace-item-w2"; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), @@ -51,6 +56,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { beforeEach(() => { navigationMock.push = vi.fn(); storeState.features.office = false; + storeState.workspaces.activeId = "w1"; storeState.setActiveWorkspace = vi.fn(); }); @@ -62,12 +68,21 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { storeState.features.office = true; render(); - fireEvent.click(screen.getByText("Add workspace")); + fireEvent.click(screen.getByText("New office workspace")); expect(navigationMock.push).toHaveBeenCalledWith("/office/setup?mode=new"); }); - it("routes to the settings workspaces page when the office feature is disabled", () => { + it("routes to the settings workspaces page for a new kanban workspace", () => { + storeState.features.office = true; + render(); + + fireEvent.click(screen.getByText("New kanban workspace")); + + expect(navigationMock.push).toHaveBeenCalledWith("/settings/workspace"); + }); + + it("keeps the legacy add-workspace route when the office feature is disabled", () => { storeState.features.office = false; render(); @@ -85,6 +100,8 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { beforeEach(() => { navigationMock.push = vi.fn(); + storeState.features.office = false; + storeState.workspaces.activeId = "w1"; storeState.setActiveWorkspace = vi.fn(); cookieWrites = []; cookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie"); @@ -104,14 +121,25 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { cleanup(); }); + it("does nothing when selecting the already active workspace", () => { + storeState.features.office = false; + render(); + + fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); + + expect(cookieWrites).toEqual([]); + expect(storeState.setActiveWorkspace).not.toHaveBeenCalled(); + expect(navigationMock.push).not.toHaveBeenCalled(); + }); + it("writes the active-workspace cookie and updates the store on select", () => { storeState.features.office = false; render(); - fireEvent.click(screen.getByTestId("sidebar-workspace-item-w1")); + fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); - expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w1"))).toBe(true); - expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1"); + expect(cookieWrites.some((c) => c.startsWith("office-active-workspace=w2"))).toBe(true); + expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w2"); expect(navigationMock.push).not.toHaveBeenCalled(); }); @@ -119,9 +147,40 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { storeState.features.office = true; render(); - fireEvent.click(screen.getByTestId("sidebar-workspace-item-w1")); + fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); + + expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w2"); + expect(navigationMock.push).toHaveBeenCalledWith("/office?workspaceId=w2"); + }); + + it("navigates to the kanban board when an office user selects a kanban workspace", () => { + storeState.features.office = true; + storeState.workspaces.activeId = "w2"; + render(); + + fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w1"); - expect(navigationMock.push).toHaveBeenCalledWith("/office?workspaceId=w1"); + expect(navigationMock.push).toHaveBeenCalledWith("/?workspaceId=w1"); + }); + + it("labels workspace types in the menu without using trigger space", () => { + storeState.workspaces.activeId = "w1"; + render(); + + expect(screen.getByTestId("sidebar-workspace-trigger").textContent).not.toContain("Kanban"); + expect(screen.getByTestId(KANBAN_WORKSPACE_ITEM).textContent).toContain("Kanban"); + expect(screen.getByTestId(OFFICE_WORKSPACE_ITEM).textContent).toContain("Office"); + }); + + it("still no-ops on the active workspace when office routing is enabled", () => { + storeState.features.office = true; + render(); + + fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); + + expect(cookieWrites).toEqual([]); + expect(storeState.setActiveWorkspace).not.toHaveBeenCalled(); + expect(navigationMock.push).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index f2b80e4d39..b3fa815608 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -2,7 +2,13 @@ import { forwardRef, useCallback, useState, type ComponentPropsWithoutRef } from "react"; import { useRouter } from "next/navigation"; -import { IconCheck, IconChevronDown, IconPlus } from "@tabler/icons-react"; +import { + IconBriefcase, + IconCheck, + IconChevronDown, + IconLayoutKanban, + IconPlus, +} from "@tabler/icons-react"; import { DropdownMenu, DropdownMenuContent, @@ -45,6 +51,29 @@ const WorkspaceTrigger = forwardRef< ); }); +type WorkspaceType = "kanban" | "office"; + +type WorkspaceItem = { + id: string; + name: string; + office_workflow_id?: string | null; +}; + +function workspaceType(workspace: WorkspaceItem | undefined): WorkspaceType { + return workspace?.office_workflow_id ? "office" : "kanban"; +} + +function workspaceTypeLabel(type: WorkspaceType) { + return type === "office" ? "Office" : "Kanban"; +} + +function WorkspaceTypeIcon({ type, className }: { type: WorkspaceType; className: string }) { + if (type === "office") { + return ; + } + return ; +} + export function AppSidebarWorkspacePicker() { const router = useRouter(); const officeEnabled = useFeature("office"); @@ -57,15 +86,21 @@ export function AppSidebarWorkspacePicker() { const activeName = activeWorkspace?.name ?? "Workspace"; const handleSelect = useCallback( - (id: string) => { + (workspace: WorkspaceItem) => { + const { id } = workspace; + if (id === activeId) { + setOpen(false); + return; + } document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; setActiveWorkspace(id); if (officeEnabled) { - router.push(`/office?workspaceId=${id}`); + const target = workspaceType(workspace) === "office" ? "/office" : "/"; + router.push(`${target}?workspaceId=${id}`); } setOpen(false); }, - [router, setActiveWorkspace, officeEnabled], + [activeId, router, setActiveWorkspace, officeEnabled], ); return ( @@ -73,32 +108,59 @@ export function AppSidebarWorkspacePicker() { - + {workspaces.items.length === 0 ? ( No workspaces ) : ( - workspaces.items.map((ws) => ( - handleSelect(ws.id)} - className="cursor-pointer gap-2" - > - {ws.name} - {ws.id === activeId && } - - )) + workspaces.items.map((ws) => { + const type = workspaceType(ws); + return ( + handleSelect(ws)} + className="cursor-pointer gap-2" + > + + {ws.name} + + {workspaceTypeLabel(type)} + + {ws.id === activeId && } + + ); + }) )} - { - router.push(officeEnabled ? "/office/setup?mode=new" : "/settings/workspace"); - }} - > - - Add workspace - + {officeEnabled ? ( + <> + router.push("/settings/workspace")} + > + + New kanban workspace + + router.push("/office/setup?mode=new")} + > + + New office workspace + + + ) : ( + router.push("/settings/workspace")} + > + + Add workspace + + )} ); diff --git a/apps/web/e2e/tests/office/onboarding.spec.ts b/apps/web/e2e/tests/office/onboarding.spec.ts index ee0657df8e..47bfdf819c 100644 --- a/apps/web/e2e/tests/office/onboarding.spec.ts +++ b/apps/web/e2e/tests/office/onboarding.spec.ts @@ -74,17 +74,17 @@ test.describe("Onboarding", () => { }); }); - test('"Add workspace" button opens setup and close returns to homepage', async ({ + test('"New office workspace" opens setup and close returns to homepage', async ({ testPage, officeSeed: _, }) => { await testPage.goto("/office"); // The unified AppSidebar overhaul folded the workspace switcher into a - // dropdown in the sidebar header. "Add workspace" is now a menu item - // (role=menuitem) inside that dropdown, reached by opening the picker - // via its `sidebar-workspace-trigger` button. + // dropdown in the sidebar header. New office workspace is a menu item + // inside that dropdown, reached by opening the picker via its + // `sidebar-workspace-trigger` button. await testPage.getByTestId("sidebar-workspace-trigger").click(); - await testPage.getByRole("menuitem", { name: "Add workspace" }).click(); + await testPage.getByRole("menuitem", { name: "New office workspace" }).click(); await expect(testPage).toHaveURL(/\/office\/setup\?mode=new/, { timeout: 10_000 }); await expect( testPage.getByRole("heading", { name: "Set up your Office workspace" }), diff --git a/apps/web/e2e/tests/office/workspace-picker.spec.ts b/apps/web/e2e/tests/office/workspace-picker.spec.ts new file mode 100644 index 0000000000..dc2242cad7 --- /dev/null +++ b/apps/web/e2e/tests/office/workspace-picker.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "../../fixtures/office-fixture"; + +test.describe("Office workspace picker", () => { + test("labels workspace types and keeps the current workspace selection as a no-op", async ({ + testPage, + apiClient, + officeSeed, + }) => { + const kanbanWorkspace = await apiClient.createWorkspace("Picker Kanban Workspace"); + + await testPage.goto("/office"); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); + await expect(testPage).toHaveURL(/\/office$/); + + await testPage.getByTestId("sidebar-workspace-trigger").click(); + + const kanbanWorkspaceItem = testPage.getByTestId( + `sidebar-workspace-item-${kanbanWorkspace.id}`, + ); + const officeWorkspace = testPage.getByTestId( + `sidebar-workspace-item-${officeSeed.workspaceId}`, + ); + await expect(kanbanWorkspaceItem).toContainText("Kanban"); + await expect(officeWorkspace).toContainText("Office"); + + await officeWorkspace.click(); + + await expect(testPage).toHaveURL(/\/office$/); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); + + await testPage.goto("/office"); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); + await testPage.getByTestId("sidebar-workspace-trigger").click(); + const reopenedKanbanWorkspaceItem = testPage.getByTestId( + `sidebar-workspace-item-${kanbanWorkspace.id}`, + ); + await expect(reopenedKanbanWorkspaceItem).toBeVisible(); + await reopenedKanbanWorkspaceItem.click(); + await expect(testPage).toHaveURL( + (url) => { + return url.pathname === "/" && url.searchParams.get("workspaceId") === kanbanWorkspace.id; + }, + { timeout: 10_000 }, + ); + }); + + test("offers separate kanban and office workspace creation actions", async ({ + testPage, + officeSeed: _, + }) => { + await testPage.goto("/office"); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); + + await testPage.getByTestId("sidebar-workspace-trigger").click(); + await testPage.getByRole("menuitem", { name: "New office workspace" }).click(); + await expect(testPage).toHaveURL(/\/office\/setup\?mode=new/, { timeout: 10_000 }); + await expect( + testPage.getByRole("heading", { name: "Set up your Office workspace" }), + ).toBeVisible({ timeout: 10_000 }); + + await testPage.goto("/office"); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); + + await testPage.getByTestId("sidebar-workspace-trigger").click(); + await testPage.getByRole("menuitem", { name: "New kanban workspace" }).click(); + await expect(testPage).toHaveURL(/\/settings\/workspace$/, { timeout: 10_000 }); + }); +}); diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index b66297e5f0..1392848f64 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -10,6 +10,7 @@ export type WorkspaceState = { default_environment_id?: string | null; default_agent_profile_id?: string | null; default_config_agent_profile_id?: string | null; + office_workflow_id?: string | null; created_at: string; updated_at: string; }>;