diff --git a/packages/app/e2e/regression/multi-server.spec.ts b/packages/app/e2e/regression/multi-server.spec.ts index e42837e00402..9903ec224737 100644 --- a/packages/app/e2e/regression/multi-server.spec.ts +++ b/packages/app/e2e/regression/multi-server.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test" import { REMOTE_SERVER, SERVER, + T0, expectPath, holdRoute, project, @@ -386,3 +387,137 @@ test("a project's auto-accept choice overrides the default in both directions", await page.getByRole("option", { name: "Use default (On)" }).click() await expect.poll(() => replies).toEqual(["permission-api", "permission-web"]) }) + +test("customized vertical sidebar merges a project across servers and narrows it with the view menu", async ({ + page, +}) => { + // The same repo on two machines shares its project ID, so both servers' sessions share one group. + const shared = (directory: string) => project({ id: "proj_shared", directory, name: "shared" }) + + const local = session({ id: "ses_local_open", directory: directoryA, title: "Local open", projectID: "proj_shared" }) + + const recent = session({ + id: "ses_local_recent", + directory: directoryA, + title: "Local recent", + projectID: "proj_shared", + created: T0 - 60_000, + }) + + const remote = session({ + id: "ses_remote_open", + directory: directoryB, + title: "Remote open", + projectID: "proj_shared", + }) + + const config = (directory: string, sessions: ReturnType[]) => ({ + directory, + project: shared(directory), + provider: { all: [], connected: [], default: {} }, + sessions, + pageMessages: () => ({ items: [] }), + }) + + await mockServers(page, { + [SERVER]: config(directoryA, [local, recent]), + [REMOTE_SERVER]: config(directoryB, [remote]), + }) + await seed(page, { + servers: [REMOTE_SERVER], + tabs: [local.id, { session: remote.id, server: REMOTE_SERVER }], + settings: { appearance: { tabLayout: "vertical" }, general: { sidebarOrganize: "customize" } }, + }) + await page.goto(sessionHref(local.id)) + await expectSessionTitle(page, local.title) + + const sidebar = page.locator('[data-slot="vertical-tabs"]') + + const groups = sidebar.locator("[data-sidebar-group]") + + const titles = sidebar.locator("[data-titlebar-tab-slot] [data-titlebar-tab-title]") + + await expect(groups).toHaveText([/shared$/]) + await expect(titles).toHaveText([local.title, remote.title]) + // Only the tab from the other machine names its server. + await expect(sidebar.locator('[data-slot="tab-label"]')).toHaveCount(1) + await expect(sidebar.getByRole("button", { name: recent.title })).toBeVisible() + + // Collapsing the group that holds the current session keeps only that session below it. + const header = groups.first().locator("button[aria-expanded]") + + await header.click() + await expect(header).toHaveAttribute("aria-expanded", "false") + // Only a collapsed group counts its open tabs. + await expect(groups).toHaveText([/shared\s*2$/]) + await expect(titles).toHaveText([local.title]) + await expect(sidebar.getByRole("button", { name: recent.title })).toHaveCount(0) + await header.click() + await expect(titles).toHaveText([local.title, remote.title]) + + const menu = page.getByRole("menu") + + await page.getByRole("button", { name: "View options" }).click() + + // Moving between submenu triggers swaps the open submenu instead of stacking them. + for (const [name, item] of [ + [/^Group by/, "Server → Project"], + [/^Sort by/, "Attention"], + ] as const) { + const box = await menu.getByRole("menuitem", { name }).boundingBox() + + if (!box) throw new Error(`no ${name} trigger`) + await page.mouse.move(box.x + 40, box.y + box.height / 2, { steps: 12 }) + await expect(menu.last()).toContainText(item) + } + + await expect(menu).toHaveCount(2) + await menu.getByRole("menuitem", { name: /^Group by/ }).click() + await page.getByRole("menuitemradio", { name: "Server → Project" }).click() + await page.keyboard.press("Escape") + await expect(groups).toHaveCount(4) + await expect(sidebar.locator('[data-slot="tab-label"]')).toHaveCount(0) + + // A server filter hides the other server's rows, but never the active tab. + await page.getByRole("button", { name: "View options" }).click() + await page.getByRole("menuitem", { name: /^Server/ }).click() + await page.getByRole("menuitemcheckbox").last().click() + await page.keyboard.press("Escape") + await page.keyboard.press("Escape") + await expect(titles).toHaveText([local.title, remote.title]) + await expect(sidebar.getByRole("button", { name: recent.title })).toHaveCount(0) + await page.getByRole("button", { name: "View options" }).click() + await menu.getByRole("menuitem", { name: "Reset filters" }).click() + await expect(sidebar.getByRole("button", { name: recent.title })).toBeVisible() +}) + +test("deleting a session's tab in the customized sidebar removes it from the list too", async ({ page }) => { + const first = session({ id: "ses_delete_first", directory: directoryA, title: "Delete me" }) + + const second = session({ id: "ses_delete_second", directory: directoryA, title: "Keep me" }) + + await mockServers(page, { + [SERVER]: { + directory: directoryA, + project: project({ id: "proj_delete", directory: directoryA }), + provider: { all: [], connected: [], default: {} }, + sessions: [first, second], + pageMessages: () => ({ items: [] }), + }, + }) + await seed(page, { + tabs: [first.id, second.id], + settings: { appearance: { tabLayout: "vertical" }, general: { sidebarOrganize: "customize" } }, + }) + await page.goto(sessionHref(first.id)) + await expectSessionTitle(page, first.title) + + const sidebar = page.locator('[data-slot="vertical-tabs"]') + + await sidebar.locator("[data-titlebar-tab-title]", { hasText: first.title }).click({ button: "right" }) + await page.getByRole("menuitem", { name: "Delete…", exact: true }).click() + await page.getByRole("dialog").getByRole("button", { name: "Delete session", exact: true }).click() + await expectSessionTitle(page, second.title) + // The session the app had loaded must not come back as a recent row, which would open a missing session. + await expect(sidebar.getByText(first.title)).toHaveCount(0) +}) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index c51b7ba270fb..59cc959024ce 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -856,11 +856,42 @@ export const dict = { "Group side tabs by project or show one list. Only applies to side tabs.", "settings.experimental.sidebarOrganize.projects": "Projects", "settings.experimental.sidebarOrganize.list": "One list", + "settings.experimental.sidebarOrganize.customize": "Customize", "sidebar.organize.projects": "Projects", "sidebar.organize.openTab": "Open tab", "sidebar.organize.showMore": "Show more", "sidebar.organize.showLess": "Show less", "sidebar.organize.empty": "No sessions yet", + "sidebar.view.sessions": "Sessions", + "sidebar.view.options": "View options", + "sidebar.view.grouping": "Group by", + "sidebar.view.grouping.none": "None", + "sidebar.view.grouping.project": "Project", + "sidebar.view.grouping.server-project": "Server → Project", + "sidebar.view.grouping.server": "Server", + "sidebar.view.ordering": "Sort by", + "sidebar.view.ordering.manual": "Manual", + "sidebar.view.ordering.updated": "Updated", + "sidebar.view.ordering.created": "Created", + "sidebar.view.ordering.attention": "Attention", + "sidebar.view.ordering.hint": "Open tabs stay above recent sessions", + "sidebar.view.show": "Show", + "sidebar.view.show.recent": "Recent sessions", + "sidebar.view.show.subagents": "Subagents", + "sidebar.view.show.serverLabels": "Server labels", + "sidebar.view.filter.status": "Status", + "sidebar.view.filter.server": "Server", + "sidebar.view.filter.project": "Project", + "sidebar.view.status.input": "Needs input", + "sidebar.view.status.running": "Running", + "sidebar.view.status.unread": "Unread", + "sidebar.view.status.failed": "Failed", + "sidebar.view.status.idle": "Idle", + "sidebar.view.project.none": "No project", + "sidebar.view.other": "Other", + "sidebar.view.resetFilters": "Reset filters", + "sidebar.view.markAllRead": "Mark all as read", + "sidebar.view.noMatch": "No other sessions match", "settings.plugins.reload": "Reload", "settings.plugins.status.loading": "Loading", "settings.plugins.status.failed": "Failed", diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index 81aad8ab2322..86aa80576e86 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -35,7 +35,7 @@ export const SettingsExperimental: Component = () => { >