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