From 1c9f68fd9f8646b0412b2574220ac0614cae1407 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 4 Mar 2026 23:50:38 +0000 Subject: [PATCH 1/6] feat(app): add pinned workspaces in sidebar --- packages/app/e2e/actions.ts | 14 ++++ packages/app/e2e/projects/workspaces.spec.ts | 66 +++++++++++++++++++ packages/app/e2e/selectors.ts | 3 + packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/layout.tsx | 48 +++++++++++++- packages/app/src/pages/layout/helpers.ts | 25 +++++++ .../src/pages/layout/sidebar-workspace.tsx | 18 +++++ 7 files changed, 174 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba61752..4d18e6d1785 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -17,6 +17,7 @@ import { listItemKeyStartsWithSelector, workspaceItemSelector, workspaceMenuTriggerSelector, + workspacePinToggleSelector, } from "./selectors" import type { createSdk } from "./utils" @@ -576,3 +577,16 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { await expect(menu).toBeVisible() return menu } + +export async function setWorkspacePinned(page: Page, workspaceSlug: string, enabled: boolean) { + const menu = await openWorkspaceMenu(page, workspaceSlug) + const toggle = menu.locator(workspacePinToggleSelector(workspaceSlug)).first() + await expect(toggle).toBeVisible() + const name = await toggle.textContent() + const pinned = (name ?? "").toLowerCase().includes("unpin") + if (pinned === enabled) { + await page.keyboard.press("Escape") + return + } + await toggle.click({ force: true }) +} diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 3867395267b..6abd08365ea 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -13,6 +13,7 @@ import { confirmDialog, openSidebar, openWorkspaceMenu, + setWorkspacePinned, setWorkspacesEnabled, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" @@ -302,6 +303,71 @@ test("can delete a workspace", async ({ page, withProject }) => { }) }) +test("can pin and unpin a workspace with persistence", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + const workspaces = [] as string[] + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + const list = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs.filter((slug) => slug !== rootSlug && (slug === a || slug === b)).slice(0, 2) + } + + const listAll = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs.filter((slug) => slug === rootSlug || slug === a || slug === b).slice(0, 3) + } + + await expect.poll(async () => await list()).toHaveLength(2) + const before = await list() + + await setWorkspacePinned(page, a, true) + await expect.poll(async () => await list()).toEqual([a, b]) + + await setWorkspacePinned(page, rootSlug, false) + await expect.poll(async () => (await listAll())[0]).toBe(a) + + await setWorkspacePinned(page, rootSlug, true) + await expect.poll(async () => (await listAll())[0]).toBe(rootSlug) + + await page.reload() + await openSidebar(page) + await expect.poll(async () => await list()).toEqual([a, b]) + + await setWorkspacePinned(page, a, false) + await expect.poll(async () => await list()).toEqual(before) + }) +}) + test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b52..39a060488bb 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -61,6 +61,9 @@ export const workspaceItemSelector = (slug: string) => export const workspaceMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` +export const workspacePinToggleSelector = (slug: string) => + `[data-action="workspace-pin-toggle"][data-workspace="${slug}"]` + export const workspaceNewSessionSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]` diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..1db305a7ccf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -820,6 +820,8 @@ export const dict = { "session.delete.button": "Delete session", "workspace.new": "New workspace", + "workspace.pin": "Pin", + "workspace.unpin": "Unpin", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Failed to create workspace", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2fd2f2fe3dd..1fbcecc3236 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -60,6 +60,7 @@ import { useLanguage, type Locale } from "@/context/language" import { childMapByParent, displayName, + effectiveWorkspacePinnedOrder, effectiveWorkspaceOrder, errorMessage, getDraggableId, @@ -87,6 +88,7 @@ export default function Layout(props: ParentProps) { activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, workspaceOrder: {} as Record, + workspacePinned: {} as Record, workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, @@ -1051,6 +1053,11 @@ export default function Layout(props: ParentProps) { ) if (known) return known[0] + const knownPinned = Object.entries(store.workspacePinned).find( + ([root, dirs]) => root === directory || dirs.includes(directory), + ) + if (knownPinned) return knownPinned[0] + const [child] = globalSync.child(directory, { bootstrap: false }) const id = child.project if (!id) return directory @@ -1214,6 +1221,20 @@ export default function Layout(props: ParentProps) { setWorkspaceName(directory, next, projectId, branch) } + const workspacePinned = (root: string, directory: string) => { + const key = workspaceKey(directory) + return (store.workspacePinned[root] ?? []).some((item) => workspaceKey(item) === key) + } + + const setWorkspacePinned = (root: string, directory: string, value: boolean) => { + const key = workspaceKey(directory) + setStore("workspacePinned", root, (prev) => { + const next = (prev ?? []).filter((item) => workspaceKey(item) !== key) + if (!value) return next + return [directory, ...next] + }) + } + function closeProject(directory: string) { const list = layout.projects.list() const index = list.findIndex((x) => x.worktree === directory) @@ -1317,7 +1338,9 @@ export default function Layout(props: ParentProps) { project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory) }), ) - setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) + const key = workspaceKey(directory) + setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspaceKey(workspace) !== key)) + setStore("workspacePinned", root, (pinned) => (pinned ?? []).filter((workspace) => workspaceKey(workspace) !== key)) layout.projects.close(directory) layout.projects.open(root) @@ -1651,7 +1674,12 @@ export default function Layout(props: ParentProps) { const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false - const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) + const ordered = effectiveWorkspacePinnedOrder( + local, + dirs, + store.workspaceOrder[project.worktree], + store.workspacePinned[project.worktree], + ) if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] if (!extra) return ordered if (pending) return ordered @@ -1684,6 +1712,11 @@ export default function Layout(props: ParentProps) { if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return + const from = ids[fromIndex] + const to = ids[toIndex] + if (!from || !to) return + if (workspacePinned(project.worktree, from) !== workspacePinned(project.worktree, to)) return + const result = ids.slice() const [item] = result.splice(fromIndex, 1) if (!item) return @@ -1763,6 +1796,8 @@ export default function Layout(props: ParentProps) { dialog.show(() => ), showDeleteWorkspaceDialog: (root, directory) => dialog.show(() => ), + workspacePinned, + setWorkspacePinned, setScrollContainerRef: (el, mobile) => { if (!mobile) scrollContainerRef = el }, @@ -1806,6 +1841,14 @@ export default function Layout(props: ParentProps) { }) const projectId = createMemo(() => panelProps.project?.id ?? "") const workspaces = createMemo(() => workspaceIds(panelProps.project)) + const firstUnpinned = createMemo(() => { + const project = panelProps.project + if (!project) return + const list = workspaces() + const split = list.findIndex((directory) => !workspacePinned(project.worktree, directory)) + if (split <= 0) return + return list[split] + }) const unseenCount = createMemo(() => workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) @@ -1981,6 +2024,7 @@ export default function Layout(props: ParentProps) { directory={directory} project={p()} sortNow={sortNow} + divider={directory === firstUnpinned()} mobile={panelProps.mobile} /> )} diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 42315e5893c..8ee3e4f330d 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -99,4 +99,29 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted return [...result, ...live.values()] } +export const effectiveWorkspacePinnedOrder = ( + local: string, + dirs: string[], + persisted?: string[], + pinned?: string[], +) => { + const ordered = effectiveWorkspaceOrder(local, dirs, persisted) + if (!pinned?.length) return ordered + + const set = new Set(pinned.map((dir) => workspaceKey(dir))) + if (set.size === 0) return ordered + + const pinnedDirs = [] as string[] + const rest = [] as string[] + for (const dir of ordered) { + if (set.has(workspaceKey(dir))) { + pinnedDirs.push(dir) + continue + } + rest.push(dir) + } + + return [...pinnedDirs, ...rest] +} + export const syncWorkspaceOrder = effectiveWorkspaceOrder diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 43d99cf8954..b056e80e21a 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -52,6 +52,8 @@ export type WorkspaceSidebarContext = { setWorkspaceExpanded: (directory: string, value: boolean) => void showResetWorkspaceDialog: (root: string, directory: string) => void showDeleteWorkspaceDialog: (root: string, directory: string) => void + workspacePinned: (root: string, directory: string) => boolean + setWorkspacePinned: (root: string, directory: string, value: boolean) => void setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void } @@ -152,6 +154,8 @@ const WorkspaceActions = (props: { openEditor: WorkspaceSidebarContext["openEditor"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] + pinned: Accessor + setWorkspacePinned: WorkspaceSidebarContext["setWorkspacePinned"] root: string setHoverSession: WorkspaceSidebarContext["setHoverSession"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] @@ -200,6 +204,15 @@ const WorkspaceActions = (props: { > {props.language.t("common.rename")} + props.setWorkspacePinned(props.root, props.directory, !props.pinned())} + > + + {props.pinned() ? props.language.t("workspace.unpin") : props.language.t("workspace.pin")} + + props.showResetWorkspaceDialog(props.root, props.directory)} @@ -303,6 +316,7 @@ export const SortableWorkspace = (props: { directory: string project: LocalProject sortNow: Accessor + divider?: boolean mobile?: boolean }): JSX.Element => { const navigate = useNavigate() @@ -330,6 +344,7 @@ export const SortableWorkspace = (props: { const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const busy = createMemo(() => props.ctx.isBusy(props.directory)) + const pinned = createMemo(() => props.ctx.workspacePinned(props.project.worktree, props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") @@ -359,6 +374,7 @@ export const SortableWorkspace = (props: { classList={{ "opacity-30": sortable.isActiveDraggable, "opacity-50 pointer-events-none": busy(), + "pt-3 mt-1 border-t border-border-weak-base": !!props.divider, }} > @@ -434,6 +450,8 @@ export const SortableWorkspace = (props: { openEditor={props.ctx.openEditor} showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} + pinned={pinned} + setWorkspacePinned={props.ctx.setWorkspacePinned} root={props.project.worktree} setHoverSession={props.ctx.setHoverSession} clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} From aa7e2a01af635cd46fe4232e3316fab5cac5296f Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 00:30:02 +0000 Subject: [PATCH 2/6] test(app): stabilize workspace pin e2e on windows --- packages/app/e2e/projects/workspaces.spec.ts | 46 ++++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 6abd08365ea..8728ad53cc8 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -331,12 +331,28 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec const b = workspaces[1] if (!a || !b) throw new Error("Expected two created workspaces") + const key = (slug: string) => { + const dir = base64Decode(slug) + const norm = dir + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() + return norm.split("/").at(-1) ?? norm + } + + const aKey = key(a) + const bKey = key(b) + const rootKey = key(rootSlug) + const list = async () => { const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') const slugs = await nodes.evaluateAll((els) => { return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) }) - return slugs.filter((slug) => slug !== rootSlug && (slug === a || slug === b)).slice(0, 2) + return slugs.filter((slug) => { + const slugKey = key(slug) + return slugKey === aKey || slugKey === bKey + }) } const listAll = async () => { @@ -344,26 +360,38 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec const slugs = await nodes.evaluateAll((els) => { return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) }) - return slugs.filter((slug) => slug === rootSlug || slug === a || slug === b).slice(0, 3) + return slugs.filter((slug) => { + const slugKey = key(slug) + return slugKey === rootKey || slugKey === aKey || slugKey === bKey + }) + } + + const find = async (target: string) => { + const slugs = await listAll() + return slugs.find((slug) => key(slug) === target) } - await expect.poll(async () => await list()).toHaveLength(2) + await expect.poll(async () => (await list()).length).toBe(2) const before = await list() + const aSlug = await find(aKey) + if (!aSlug) throw new Error("Missing first workspace slug") - await setWorkspacePinned(page, a, true) - await expect.poll(async () => await list()).toEqual([a, b]) + await setWorkspacePinned(page, aSlug, true) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) await setWorkspacePinned(page, rootSlug, false) - await expect.poll(async () => (await listAll())[0]).toBe(a) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(aKey) await setWorkspacePinned(page, rootSlug, true) - await expect.poll(async () => (await listAll())[0]).toBe(rootSlug) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(rootKey) await page.reload() await openSidebar(page) - await expect.poll(async () => await list()).toEqual([a, b]) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) - await setWorkspacePinned(page, a, false) + const pinnedSlug = await find(aKey) + if (!pinnedSlug) throw new Error("Missing pinned workspace slug") + await setWorkspacePinned(page, pinnedSlug, false) await expect.poll(async () => await list()).toEqual(before) }) }) From 2dc0b3922c6a11bd2c504b4b2942dda33720ca73 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 00:49:17 +0000 Subject: [PATCH 3/6] test(app): dedupe workspace rows in pin e2e --- packages/app/e2e/projects/workspaces.spec.ts | 32 +++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 8728ad53cc8..98e218b236b 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -349,10 +349,18 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec const slugs = await nodes.evaluateAll((els) => { return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) }) - return slugs.filter((slug) => { - const slugKey = key(slug) - return slugKey === aKey || slugKey === bKey - }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === aKey || slugKey === bKey + }) } const listAll = async () => { @@ -360,10 +368,18 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec const slugs = await nodes.evaluateAll((els) => { return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) }) - return slugs.filter((slug) => { - const slugKey = key(slug) - return slugKey === rootKey || slugKey === aKey || slugKey === bKey - }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === rootKey || slugKey === aKey || slugKey === bKey + }) } const find = async (target: string) => { From dd48a90e75b3a35be8e496c49b05c2e76df279b3 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 00:53:59 +0000 Subject: [PATCH 4/6] test(app): expand workspace pinning coverage --- packages/app/e2e/projects/workspaces.spec.ts | 143 +++++++++++++++++- packages/app/e2e/selectors.ts | 2 + .../src/pages/layout/sidebar-workspace.tsx | 1 + 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 98e218b236b..3a461ac05ec 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -8,6 +8,7 @@ import { test, expect } from "../fixtures" test.describe.configure({ mode: "serial" }) import { + createTestProject, cleanupTestProject, clickMenuItem, confirmDialog, @@ -16,7 +17,13 @@ import { setWorkspacePinned, setWorkspacesEnabled, } from "../actions" -import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { + dropdownMenuContentSelector, + inlineInputSelector, + projectSwitchSelector, + workspaceDividerSelector, + workspaceItemSelector, +} from "../selectors" import { createSdk, dirSlug } from "../utils" function slugFromUrl(url: string) { @@ -412,6 +419,140 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec }) }) +test("workspace pinning is isolated per project", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherSlug = dirSlug(other) + const dirs = [] as string[] + + try { + await withProject( + async ({ slug }) => { + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === slug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const pinnedSlug = slugFromUrl(page.url()) + dirs.push(base64Decode(pinnedSlug)) + + await openSidebar(page) + await setWorkspacePinned(page, pinnedSlug, true) + + const pinnedMenu = await openWorkspaceMenu(page, pinnedSlug) + await expect( + pinnedMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + await page.keyboard.press("Escape") + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, otherSlug, true) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === otherSlug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const otherWorkspace = slugFromUrl(page.url()) + dirs.push(base64Decode(otherWorkspace)) + + await openSidebar(page) + const otherMenu = await openWorkspaceMenu(page, otherWorkspace) + await expect(otherMenu.getByRole("menuitem").filter({ hasText: /^Pin$/i }).first()).toBeVisible() + await page.keyboard.press("Escape") + + const rootButton = page.locator(projectSwitchSelector(slug)).first() + await expect(rootButton).toBeVisible() + await rootButton.click() + + await openSidebar(page) + const rootMenu = await openWorkspaceMenu(page, pinnedSlug) + await expect( + rootMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + }, + { extra: [other] }, + ) + } finally { + await Promise.all(dirs.map((dir) => cleanupTestProject(dir))) + await cleanupTestProject(other) + } +}) + +test("workspace divider is shown only with mixed pin state", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + await withProject(async ({ slug: rootSlug }) => { + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + const workspaces = [] as string[] + try { + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + await setWorkspacePinned(page, rootSlug, false) + await setWorkspacePinned(page, a, true) + await setWorkspacePinned(page, b, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBeGreaterThan(0) + + await setWorkspacePinned(page, a, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBe(0) + } finally { + await Promise.all(workspaces.map((slug) => cleanupTestProject(base64Decode(slug)))) + } + }) +}) + test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 39a060488bb..bd3d9afd532 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -58,6 +58,8 @@ export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} export const workspaceItemSelector = (slug: string) => `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` +export const workspaceDividerSelector = `${sidebarNavSelector} [data-component="workspace-item"][data-workspace-divider="true"]` + export const workspaceMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index b056e80e21a..d3ada2e6024 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -383,6 +383,7 @@ export const SortableWorkspace = (props: { class="group/workspace relative" data-component="workspace-item" data-workspace={base64Encode(props.directory)} + data-workspace-divider={props.divider ? "true" : undefined} >
Date: Thu, 5 Mar 2026 01:20:22 +0000 Subject: [PATCH 5/6] test(app): harden workspace pinning e2e matching --- packages/app/e2e/projects/workspaces.spec.ts | 31 +++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 3a461ac05ec..0f8e1320813 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -30,6 +30,21 @@ function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } +async function ensureWorkspacesEnabled(page: Page, slug: string) { + for (const _ of [0, 1, 2]) { + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + const visible = await page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) + if (visible) return + } + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible({ timeout: 60_000 }) +} + async function setupWorkspaceTest(page: Page, project: { slug: string }) { const rootSlug = project.slug await openSidebar(page) @@ -313,8 +328,7 @@ test("can delete a workspace", async ({ page, withProject }) => { test("can pin and unpin a workspace with persistence", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) + await ensureWorkspacesEnabled(page, rootSlug) const workspaces = [] as string[] for (const _ of [0, 1]) { @@ -339,12 +353,10 @@ test("can pin and unpin a workspace with persistence", async ({ page, withProjec if (!a || !b) throw new Error("Expected two created workspaces") const key = (slug: string) => { - const dir = base64Decode(slug) - const norm = dir + return base64Decode(slug) .replace(/[\\/]+/g, "/") .replace(/\/+$/, "") .toLowerCase() - return norm.split("/").at(-1) ?? norm } const aKey = key(a) @@ -429,8 +441,7 @@ test("workspace pinning is isolated per project", async ({ page, withProject }) try { await withProject( async ({ slug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + await ensureWorkspacesEnabled(page, slug) await page.getByRole("button", { name: "New workspace" }).first().click() await expect @@ -465,8 +476,7 @@ test("workspace pinning is isolated per project", async ({ page, withProject }) await otherButton.click() await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await openSidebar(page) - await setWorkspacesEnabled(page, otherSlug, true) + await ensureWorkspacesEnabled(page, otherSlug) await page.getByRole("button", { name: "New workspace" }).first().click() await expect @@ -514,8 +524,7 @@ test("workspace divider is shown only with mixed pin state", async ({ page, with await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) + await ensureWorkspacesEnabled(page, rootSlug) const workspaces = [] as string[] try { From af12f9d2cfa2d9ecfc70c46b85612057c2af80bf Mon Sep 17 00:00:00 2001 From: anduimagui Date: Thu, 5 Mar 2026 09:45:09 +0000 Subject: [PATCH 6/6] test(app): resolve project-scoped pin slug after switch --- packages/app/e2e/projects/workspaces.spec.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 0f8e1320813..2d90a845994 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -437,6 +437,11 @@ test("workspace pinning is isolated per project", async ({ page, withProject }) const other = await createTestProject() const otherSlug = dirSlug(other) const dirs = [] as string[] + const key = (slug: string) => + base64Decode(slug) + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() try { await withProject( @@ -458,6 +463,7 @@ test("workspace pinning is isolated per project", async ({ page, withProject }) const pinnedSlug = slugFromUrl(page.url()) dirs.push(base64Decode(pinnedSlug)) + const pinnedKey = key(pinnedSlug) await openSidebar(page) await setWorkspacePinned(page, pinnedSlug, true) @@ -504,7 +510,15 @@ test("workspace pinning is isolated per project", async ({ page, withProject }) await rootButton.click() await openSidebar(page) - const rootMenu = await openWorkspaceMenu(page, pinnedSlug) + const slugs = await page + .locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + .evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const rootSlug = slugs.find((slug) => key(slug) === pinnedKey) + if (!rootSlug) throw new Error("Could not find pinned workspace in original project") + + const rootMenu = await openWorkspaceMenu(page, rootSlug) await expect( rootMenu .getByRole("menuitem")