From 07044136c8b0ecebac7d5e8309a54453c6049e95 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Tue, 11 Aug 2026 16:35:10 +0200 Subject: [PATCH] feat(app): switch sidebar Group by from the command center (#3063) Switching the sidebar between Project and Status grouping takes three clicks today: open the display preferences menu, open Grouping, pick the mode. It is the only sidebar preference with no fast path. Typing "group" in the command center now offers a single entry that switches to the mode you are not in, so it reads "Group by status" while grouped by project and the reverse once you are in status mode. With two modes a "cycle grouping" label would not say where you land, and an entry that reads the same in both states looks like it does nothing. The entry is query-only, so the default Cmd-K list is unchanged. It does not clear the focus restore element, because it navigates nowhere and focus has to return to whatever you were typing in. The contribution is built by a pure function in root-contributions.ts so its branches can be unit tested, mirroring workspace-contributions.ts. The registration subscribes to groupMode with a narrow selector to avoid re-registering every root action when host filters are reconciled. Co-authored-by: Claude Opus 5 --- .../browser/command-center-grouping.spec.ts | 58 ++++++++++++++ .../command-center/root-contributions.test.ts | 76 +++++++++++++++++++ .../src/command-center/root-contributions.ts | 39 ++++++++++ .../src/command-center/root-registration.tsx | 35 +++++++++ packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + 13 files changed, 226 insertions(+) create mode 100644 packages/app/e2e/browser/command-center-grouping.spec.ts create mode 100644 packages/app/src/command-center/root-contributions.test.ts create mode 100644 packages/app/src/command-center/root-contributions.ts diff --git a/packages/app/e2e/browser/command-center-grouping.spec.ts b/packages/app/e2e/browser/command-center-grouping.spec.ts new file mode 100644 index 000000000..8bf78177e --- /dev/null +++ b/packages/app/e2e/browser/command-center-grouping.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +const GROUP_BY_STATUS = "Group by status"; +const GROUP_BY_PROJECT = "Group by project"; + +// Result rows carry no testID of their own, so the entries are addressed by their visible label. +async function runGroupingEntry(page: Page, label: string, absent: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("group"); + + const entry = panel.getByText(label, { exact: true }); + await expect(entry).toBeVisible({ timeout: 30_000 }); + // The entry always names the mode you are not in, so its opposite must never be listed. + await expect(panel.getByText(absent, { exact: true })).toHaveCount(0); + + await entry.click(); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 }); +} + +test.describe("Command center sidebar grouping", () => { + test.describe.configure({ timeout: 120_000 }); + + test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" }); + + try { + await gotoAppShell(page); + const projectList = page.getByTestId("sidebar-project-workspace-list-scroll"); + const statusList = page.getByTestId("sidebar-status-list-scroll"); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + + // Query-only: the grouping entry stays out of the default empty-query list. + const panel = await openCommandCenter(page); + await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0); + await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(panel).not.toBeVisible({ timeout: 30_000 }); + + await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + await expect(projectList).toHaveCount(0); + + // The only assertion that catches a flip which never reached persisted storage. + await page.reload(); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + + // Grouping persists, so the run must leave the sidebar back in project mode. + await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + await expect(statusList).toHaveCount(0); + } finally { + await seeded.cleanup().catch(() => undefined); + } + }); +}); diff --git a/packages/app/src/command-center/root-contributions.test.ts b/packages/app/src/command-center/root-contributions.test.ts new file mode 100644 index 000000000..9ac762688 --- /dev/null +++ b/packages/app/src/command-center/root-contributions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import type { CommandCenterIconProps } from "./contributions"; +import { buildGroupingContribution, type GroupingCommandCenterSource } from "./root-contributions"; + +function ProjectIcon(_props: CommandCenterIconProps) { + return null; +} + +function StatusIcon(_props: CommandCenterIconProps) { + return null; +} + +function source(groupMode: SidebarGroupMode): { + value: GroupingCommandCenterSource; + applied: SidebarGroupMode[]; +} { + const applied: SidebarGroupMode[] = []; + return { + value: { + groupMode, + labels: { + section: "Actions", + groupByProject: "Group by project", + groupByStatus: "Group by status", + }, + icons: { project: ProjectIcon, status: StatusIcon }, + setGroupMode: (mode) => applied.push(mode), + }, + applied, + }; +} + +describe("grouping command center contribution", () => { + it("offers status while grouped by project", () => { + const fixture = source("project"); + const contribution = buildGroupingContribution(fixture.value); + + expect(contribution.presentation).toMatchObject({ + title: "Group by status", + icon: StatusIcon, + }); + + contribution.run(); + expect(fixture.applied).toEqual(["status"]); + }); + + it("offers project while grouped by status", () => { + const fixture = source("status"); + const contribution = buildGroupingContribution(fixture.value); + + expect(contribution.presentation).toMatchObject({ + title: "Group by project", + icon: ProjectIcon, + }); + + contribution.run(); + expect(fixture.applied).toEqual(["project"]); + }); + + it("keeps a stable id across both modes so the registry tiebreak never moves", () => { + expect(buildGroupingContribution(source("project").value).id).toBe( + buildGroupingContribution(source("status").value).id, + ); + }); + + it("stays out of the default empty-query list", () => { + for (const mode of ["project", "status"] as const) { + const contribution = buildGroupingContribution(source(mode).value); + expect(contribution.visibility).toBe("query"); + expect(contribution.group).toBe("actions"); + // 6 is keyboard-shortcuts and 7 belongs to the workspace actions in #3013. + expect(contribution.rank).toBe(8); + } + }); +}); diff --git a/packages/app/src/command-center/root-contributions.ts b/packages/app/src/command-center/root-contributions.ts new file mode 100644 index 000000000..f9b6a6d18 --- /dev/null +++ b/packages/app/src/command-center/root-contributions.ts @@ -0,0 +1,39 @@ +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; + +export interface GroupingCommandCenterSource { + groupMode: SidebarGroupMode; + labels: { + section: string; + groupByProject: string; + groupByStatus: string; + }; + icons: { + project?: CommandCenterIcon; + status?: CommandCenterIcon; + }; + setGroupMode(mode: SidebarGroupMode): void; +} + +// One entry that always names the mode you are not in, so it can never read as a no-op. +export function buildGroupingContribution( + source: GroupingCommandCenterSource, +): CommandCenterContribution { + // Collapse into nextGroupMode() from sidebar-view-store once #2504 lands. + const target: SidebarGroupMode = source.groupMode === "project" ? "status" : "project"; + return { + id: "sidebar-grouping", + group: "actions", + groupRank: 0, + rank: 8, + keywords: ["group", "grouping", "sort", "sidebar", "project", "status"], + visibility: "query", + run: () => source.setGroupMode(target), + presentation: { + kind: "action", + title: target === "status" ? source.labels.groupByStatus : source.labels.groupByProject, + sectionTitle: source.labels.section, + icon: target === "status" ? source.icons.status : source.icons.project, + }, + }; +} diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index 25d88f20c..faa89e0fc 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -3,6 +3,8 @@ import { router, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; import { CalendarClock, + CircleDashed, + Folder, FolderPlus, History, Home, @@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher" import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { buildOpenProjectRoute, @@ -28,6 +31,7 @@ import { import { getShortcutOs } from "@/utils/shortcut-platform"; import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions"; import { useCommandCenterActions } from "./provider"; +import { buildGroupingContribution } from "./root-contributions"; const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted })); const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({ @@ -46,6 +50,10 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({ color: theme.colors.foregroundMuted, })); const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({ + color: theme.colors.foregroundMuted, +})); function PlusIcon({ size }: CommandCenterIconProps) { return ; @@ -75,6 +83,14 @@ function HomeIcon({ size }: CommandCenterIconProps) { return ; } +function FolderIcon({ size }: CommandCenterIconProps) { + return ; +} + +function CircleDashedIcon({ size }: CommandCenterIconProps) { + return ; +} + export function CommandCenterRootActions() { const { t } = useTranslation(); const { overrides } = useKeyboardShortcutOverrides(); @@ -85,6 +101,10 @@ export function CommandCenterRootActions() { const sessionsRoute = useMemo(() => buildSessionsRoute(), []); const schedulesRoute = useMemo(() => buildSchedulesRoute(), []); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); + // Narrow selector on purpose: a whole-store subscription would re-register every root action + // each time host filters are reconciled. + const groupMode = useSidebarViewStore((state) => state.groupMode); + const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); const shortcutPlatform = useMemo( () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), [], @@ -229,13 +249,28 @@ export function CommandCenterRootActions() { }); } + availableActions.push( + buildGroupingContribution({ + groupMode, + labels: { + section: t("shell.commandCenter.actions"), + groupByProject: t("shell.commandCenter.groupByProject"), + groupByStatus: t("shell.commandCenter.groupByStatus"), + }, + icons: { project: FolderIcon, status: CircleDashedIcon }, + setGroupMode, + }), + ); + return availableActions; }, [ + groupMode, homeRoute, openAddProject, overrides, schedulesRoute, sessionsRoute, + setGroupMode, setShortcutsDialogOpen, settingsRoute, shortcutPlatform, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c5c5b0431..f96174068 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -65,6 +65,8 @@ export const ar: TranslationResources = { newAgent: "وكيل جديد", addProject: "إضافة مشروع", home: "بيت", + groupByProject: "تجميع حسب المشروع", + groupByStatus: "تجميع حسب الحالة", modelGroupLabel: "النموذج", modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج", thinkingGroupLabel: "التفكير", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index dde57b1ce..9c18be9d5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -63,6 +63,8 @@ export const en = { newAgent: "New agent", addProject: "Add project", home: "Home", + groupByProject: "Group by project", + groupByStatus: "Group by status", modelGroupLabel: "Model", modelSearchKeywords: "switch model change model set model select model", thinkingGroupLabel: "Thinking", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b286bf630..3f468de94 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -65,6 +65,8 @@ export const es: TranslationResources = { newAgent: "Nuevo agente", addProject: "Agregar proyecto", home: "Hogar", + groupByProject: "Agrupar por proyecto", + groupByStatus: "Agrupar por estado", modelGroupLabel: "Modelo", modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo", thinkingGroupLabel: "Razonamiento", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d7e05cd9c..482617e91 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -66,6 +66,8 @@ export const fr: TranslationResources = { newAgent: "Nouvel agent", addProject: "Ajouter un projet", home: "Maison", + groupByProject: "Grouper par projet", + groupByStatus: "Grouper par statut", modelGroupLabel: "Modèle", modelSearchKeywords: "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 529d6e157..787b0458b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -65,6 +65,8 @@ export const ja: TranslationResources = { newAgent: "新しいエージェント", addProject: "プロジェクトを追加", home: "ホーム", + groupByProject: "プロジェクトでグループ化", + groupByStatus: "ステータスでグループ化", modelGroupLabel: "モデル", modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択", thinkingGroupLabel: "思考", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f242d311a..33e4c9c3a 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -65,6 +65,8 @@ export const ko: TranslationResources = { newAgent: "새 에이전트", addProject: "프로젝트 추가", home: "홈", + groupByProject: "프로젝트별 그룹화", + groupByStatus: "상태별 그룹화", modelGroupLabel: "모델", modelSearchKeywords: "모델 전환 모델 변경 모델 설정 모델 선택", thinkingGroupLabel: "추론", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f307377b7..2349fea3c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -65,6 +65,8 @@ export const ptBR: TranslationResources = { newAgent: "Novo agente", addProject: "Adicionar projeto", home: "Início", + groupByProject: "Agrupar por projeto", + groupByStatus: "Agrupar por status", modelGroupLabel: "Modelo", modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo", thinkingGroupLabel: "Raciocínio", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1aacec974..9528a8503 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -65,6 +65,8 @@ export const ru: TranslationResources = { newAgent: "Новый агент", addProject: "Добавить проект", home: "Дом", + groupByProject: "Группировать по проекту", + groupByStatus: "Группировать по статусу", modelGroupLabel: "Модель", modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель", thinkingGroupLabel: "Мышление", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1d53b78de..08ada0995 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -65,6 +65,8 @@ export const zhCN: TranslationResources = { newAgent: "新建 Agent", addProject: "添加 project", home: "首页", + groupByProject: "按项目分组", + groupByStatus: "按状态分组", modelGroupLabel: "模型", modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型", thinkingGroupLabel: "思考",