fix: distinguish sidebar workspace types (#1364)

This commit is contained in:
Carlos Florêncio
2026-06-14 18:27:40 +01:00
committed by GitHub
parent 0c0dab26d6
commit 35a12fdb2c
8 changed files with 239 additions and 43 deletions
+1
View File
@@ -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;
};
+9 -5
View File
@@ -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,
);
+1
View File
@@ -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,
};
@@ -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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
@@ -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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM));
expect(cookieWrites).toEqual([]);
expect(storeState.setActiveWorkspace).not.toHaveBeenCalled();
expect(navigationMock.push).not.toHaveBeenCalled();
});
});
@@ -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 <IconBriefcase className={className} />;
}
return <IconLayoutKanban className={className} />;
}
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() {
<DropdownMenuTrigger asChild>
<WorkspaceTrigger activeName={activeName} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuContent align="start" className="w-72">
{workspaces.items.length === 0 ? (
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
) : (
workspaces.items.map((ws) => (
<DropdownMenuItem
key={ws.id}
data-testid={`sidebar-workspace-item-${ws.id}`}
onSelect={() => handleSelect(ws.id)}
className="cursor-pointer gap-2"
>
<span className="flex-1 truncate">{ws.name}</span>
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5" />}
</DropdownMenuItem>
))
workspaces.items.map((ws) => {
const type = workspaceType(ws);
return (
<DropdownMenuItem
key={ws.id}
data-testid={`sidebar-workspace-item-${ws.id}`}
onSelect={() => handleSelect(ws)}
className="cursor-pointer gap-2"
>
<WorkspaceTypeIcon
type={type}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate">{ws.name}</span>
<span className="shrink-0 rounded border border-border/60 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
{workspaceTypeLabel(type)}
</span>
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5 shrink-0" />}
</DropdownMenuItem>
);
})
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => {
router.push(officeEnabled ? "/office/setup?mode=new" : "/settings/workspace");
}}
>
<IconPlus className="h-3.5 w-3.5" />
<span>Add workspace</span>
</DropdownMenuItem>
{officeEnabled ? (
<>
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => router.push("/settings/workspace")}
>
<IconLayoutKanban className="h-3.5 w-3.5" />
<span>New kanban workspace</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => router.push("/office/setup?mode=new")}
>
<IconBriefcase className="h-3.5 w-3.5" />
<span>New office workspace</span>
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => router.push("/settings/workspace")}
>
<IconPlus className="h-3.5 w-3.5" />
<span>Add workspace</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
+5 -5
View File
@@ -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" }),
@@ -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 });
});
});
@@ -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;
}>;