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: "思考",