From a5a15f12ea178f86cb8ca8c482bcc823cbe9d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 27 Aug 2026 14:49:42 +0000 Subject: [PATCH] fix(ui): group sidebar tasks by repository combination (#3091) --- .../session-task-switcher-sheet-item.test.ts | 51 +++++ .../session-task-switcher-sheet-item.ts | 3 + apps/web/components/task/task-item.tsx | 5 +- .../task/task-session-sidebar-item.test.ts | 33 ++++ .../task/task-session-sidebar-item.ts | 2 + .../tests/task/mobile-sidebar-views.spec.ts | 65 ++++++ .../web/e2e/tests/task/sidebar-layout.spec.ts | 75 +++++++ .../sidebar-repository-grouping-helpers.ts | 33 ++++ apps/web/lib/sidebar/apply-view.test.ts | 185 ++++++++++++++++++ apps/web/lib/sidebar/apply-view.ts | 50 ++++- .../sidebar/sidebar-task-repositories.test.ts | 52 +++++ .../lib/sidebar/sidebar-task-repositories.ts | 32 +++ .../sidebar-multi-repository-grouping/plan.md | 110 +++++++++++ .../task-01-correct-repository-grouping.md | 103 ++++++++++ .../task-02-prove-responsive-grouping.md | 86 ++++++++ docs/specs/ui/README.md | 2 + .../sidebar-repository-grouping.md | 46 +++++ .../sidebar-repository-grouping.md | 79 ++++++++ 18 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts create mode 100644 apps/web/e2e/tests/task/sidebar-repository-grouping-helpers.ts create mode 100644 apps/web/lib/sidebar/sidebar-task-repositories.test.ts create mode 100644 apps/web/lib/sidebar/sidebar-task-repositories.ts create mode 100644 docs/plans/sidebar-multi-repository-grouping/plan.md create mode 100644 docs/plans/sidebar-multi-repository-grouping/task-01-correct-repository-grouping.md create mode 100644 docs/plans/sidebar-multi-repository-grouping/task-02-prove-responsive-grouping.md create mode 100644 docs/specs/ui/requirements/sidebar-repository-grouping.md create mode 100644 docs/specs/ui/system-design/sidebar-repository-grouping.md diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts new file mode 100644 index 0000000000..436ce09ee5 --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { toSheetItem } from "./session-task-switcher-sheet-item"; + +type SheetTask = Parameters[0]; +type SheetCtx = Parameters[1]; + +function task(overrides: Partial = {}): SheetTask { + return { + id: "t1", + _workflowId: "wf1", + title: "Task", + state: "IN_PROGRESS", + workflowStepId: "step-1", + ...overrides, + } as SheetTask; +} + +function emptyCtx(): SheetCtx { + return { + repositoryPathsById: new Map([ + ["repo-a", "owner/repo-a"], + ["repo-b", "owner/repo-b"], + ]), + workflowNameById: new Map(), + stepTitleById: new Map(), + }; +} + +describe("toSheetItem repository projection", () => { + it("projects unique repository slugs in attachment order", () => { + const item = toSheetItem( + task({ + repositoryId: "repo-a", + repositories: [ + { id: "link-b", repository_id: "repo-b", base_branch: "main", position: 2 }, + { id: "link-a", repository_id: "repo-a", base_branch: "main", position: 1 }, + { + id: "link-a-duplicate", + repository_id: "repo-a", + base_branch: "main", + position: 3, + }, + ], + }), + emptyCtx(), + ); + + expect(item.repositories).toEqual(["owner/repo-a", "owner/repo-b"]); + expect(item.repositoryLinks).toHaveLength(3); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts index 6d19ddaf6b..4b28865a16 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-item.ts @@ -6,6 +6,7 @@ import { import type { KanbanState } from "@/lib/state/slices"; import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; +import { resolveTaskRepositorySlugs } from "@/lib/sidebar/sidebar-task-repositories"; import { effectiveTaskPendingAction } from "../task-select-helpers"; export type SheetItemCtx = { @@ -50,6 +51,7 @@ function sheetStatus(task: KanbanState["tasks"][number], ctx: SheetItemCtx) { : (task.primarySessionState as TaskSessionState | undefined), foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity, repositoryPath: sheetRepositoryPath(task, ctx), + repositories: resolveTaskRepositorySlugs(task.repositories, ctx.repositoryPathsById), diffStats: sheetDiffStats(summary), comparisonUnavailable: summary?.git?.comparison_unavailable === true, updatedAt: hasSummary ? summary?.updated_at : task.updatedAt, @@ -89,6 +91,7 @@ export function toSheetItem( isRemoteExecutor: task.isRemoteExecutor, remoteExecutorType: task.primaryExecutorType ?? undefined, remoteExecutorName: task.primaryExecutorName ?? undefined, + repositoryLinks: task.repositories, queuedCount: task.statusSummary?.queued_prompt_count, wipQueue: ctx.wipQueueByTaskId?.get(task.id), }; diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 546689e6db..f2a9e17506 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -102,8 +102,9 @@ type TaskItemProps = { onToggleSubtasks?: () => void; /** * The task's repository as a stable slug (or name). A multi-repository task - * carries its primary repository here; enumerating the rest is deferred, - * since `TaskSwitcherItem.repositories` is not populated from live data. + * carries its primary repository here for compatibility. The complete + * ordered repository combination is projected through + * `TaskSwitcherItem.repositories` for sidebar grouping. */ repositoryPath?: string; /** diff --git a/apps/web/components/task/task-session-sidebar-item.test.ts b/apps/web/components/task/task-session-sidebar-item.test.ts index d12b649a08..990c40360b 100644 --- a/apps/web/components/task/task-session-sidebar-item.test.ts +++ b/apps/web/components/task/task-session-sidebar-item.test.ts @@ -27,7 +27,40 @@ function task(overrides: Partial = {}): SidebarTask { } as SidebarTask; } +function repositoryProjectionTask(): SidebarTask { + return task({ + repositoryId: "repo-a", + repositories: [ + { id: "link-b", repository_id: "repo-b", base_branch: "main", position: 2 }, + { id: "link-a", repository_id: "repo-a", base_branch: "main", position: 1 }, + { + id: "link-a-duplicate", + repository_id: "repo-a", + base_branch: "main", + position: 3, + }, + ], + }); +} + +function repositoryProjectionContext(): SidebarContext { + return { + ...emptyContext(), + repositorySlugById: new Map([ + ["repo-a", "owner/repo-a"], + ["repo-b", "owner/repo-b"], + ]), + }; +} + describe("buildSidebarItem", () => { + it("projects unique repository slugs in attachment order", () => { + const item = buildSidebarItem(repositoryProjectionTask(), repositoryProjectionContext()); + + expect(item.repositories).toEqual(["owner/repo-a", "owner/repo-b"]); + expect(item.repositoryLinks).toHaveLength(3); + }); + it("keeps the PR aggregate state for the row icon", () => { const item = buildSidebarItem( task({ diff --git a/apps/web/components/task/task-session-sidebar-item.ts b/apps/web/components/task/task-session-sidebar-item.ts index f62c787cae..b1d8f530dd 100644 --- a/apps/web/components/task/task-session-sidebar-item.ts +++ b/apps/web/components/task/task-session-sidebar-item.ts @@ -4,6 +4,7 @@ import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import { statusSummaryActiveErrorPreview } from "@/lib/task-status-summary"; import { workflowStepTitle } from "./task-session-sidebar-aggregate"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; +import { resolveTaskRepositorySlugs } from "@/lib/sidebar/sidebar-task-repositories"; type SidebarItemContext = { repositorySlugById: Map; @@ -151,6 +152,7 @@ export function buildSidebarItem( parentTaskTitle: task.parentTaskId ? context.titleById.get(task.parentTaskId) : undefined, parentTaskId: task.parentTaskId ?? undefined, workspaceMode: task.workspaceMode, + repositories: resolveTaskRepositorySlugs(task.repositories, context.repositorySlugById), repositoryLinks: task.repositories, isPRReview: task.isPRReview ?? false, isIssueWatch: task.isIssueWatch ?? false, diff --git a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts index 43472ef3b1..d448c6d735 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts @@ -14,6 +14,11 @@ import type { Page, Locator } from "@playwright/test"; import type { ApiClient } from "../../helpers/api-client"; import { dwell } from "../../helpers/causal-waits"; import { SessionPage } from "../../pages/session-page"; +import { + repositoryName, + seedSidebarCombinationRepository, + SIDEBAR_COMBINATION_REPOSITORY_NAME, +} from "./sidebar-repository-grouping-helpers"; async function seedAndOpenSheet( testPage: Page, @@ -104,6 +109,66 @@ async function touchDrag(page: Page, source: Locator, target: Locator): Promise< } test.describe("Mobile sidebar — view system", () => { + test("multi-repository group shows the complete ordered label", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + const secondRepository = await seedSidebarCombinationRepository( + apiClient, + seedData.workspaceId, + backend.tmpDir, + ); + const primaryRepositoryName = await repositoryName( + apiClient, + seedData.workspaceId, + seedData.repositoryId, + ); + const expectedLabel = `${primaryRepositoryName}, ${SIDEBAR_COMBINATION_REPOSITORY_NAME}`; + const multiRepositoryTask = await apiClient.createTask( + seedData.workspaceId, + "Mobile multi-repository task", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repositories: [ + { repository_id: seedData.repositoryId }, + { repository_id: secondRepository.id }, + ], + }, + ); + await apiClient.createTask(seedData.workspaceId, "Mobile primary repository task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, []); + const combinationHeader = sheet.locator( + `[data-testid="sidebar-group-header"][data-group-label="${expectedLabel}"]`, + ); + await expect(combinationHeader).toHaveCount(1); + const combinationGroup = combinationHeader.locator(".."); + await expect( + combinationGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`), + ).toBeVisible(); + + const primaryGroup = sheet + .locator(`[data-testid="sidebar-group-header"][data-group-key="${primaryRepositoryName}"]`) + .locator(".."); + await expect(primaryGroup).toHaveCount(1); + await expect( + primaryGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`), + ).toHaveCount(0); + + await combinationHeader.scrollIntoViewIfNeeded(); + await prCapture.screenshot("multi-repository-group-mobile", { + caption: "Mobile task drawer uses the same complete repository combination group.", + }); + }); + test("direct creation stays touch-reachable, viewport-safe, and persists", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/sidebar-layout.spec.ts b/apps/web/e2e/tests/task/sidebar-layout.spec.ts index b50a583baf..afdca7e43a 100644 --- a/apps/web/e2e/tests/task/sidebar-layout.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-layout.spec.ts @@ -11,8 +11,83 @@ import { test, expect } from "../../fixtures/test-base"; import { SessionPage } from "../../pages/session-page"; import { dwell } from "../../helpers/causal-waits"; +import { + repositoryName, + seedSidebarCombinationRepository, + SIDEBAR_COMBINATION_REPOSITORY_NAME, +} from "./sidebar-repository-grouping-helpers"; test.describe("Sidebar layout — repo groups", () => { + test("multi-repository group shows the complete ordered label", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + const secondRepository = await seedSidebarCombinationRepository( + apiClient, + seedData.workspaceId, + backend.tmpDir, + ); + const primaryRepositoryName = await repositoryName( + apiClient, + seedData.workspaceId, + seedData.repositoryId, + ); + const expectedLabel = `${primaryRepositoryName}, ${SIDEBAR_COMBINATION_REPOSITORY_NAME}`; + const multiRepositoryTask = await apiClient.createTask( + seedData.workspaceId, + "Sidebar multi-repository task", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repositories: [ + { repository_id: seedData.repositoryId }, + { repository_id: secondRepository.id }, + ], + }, + ); + await apiClient.createTask(seedData.workspaceId, "Sidebar primary repository task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const navigationTask = await apiClient.createTask( + seedData.workspaceId, + "Sidebar grouping navigation task", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }, + ); + + await testPage.goto(`/t/${navigationTask.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const combinationHeader = session.sidebar.locator( + `[data-testid="sidebar-group-header"][data-group-label="${expectedLabel}"]`, + ); + await expect(combinationHeader).toHaveCount(1); + const combinationGroup = combinationHeader.locator(".."); + await expect( + combinationGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`), + ).toBeVisible(); + + const primaryGroup = session.sidebar + .locator(`[data-testid="sidebar-group-header"][data-group-key="${primaryRepositoryName}"]`) + .locator(".."); + await expect(primaryGroup).toHaveCount(1); + await expect( + primaryGroup.locator(`[data-task-row-id="${multiRepositoryTask.id}"]`), + ).toHaveCount(0); + + await prCapture.screenshot("multi-repository-group-desktop", { + caption: "Desktop sidebar groups the task under its complete repository combination.", + }); + }); + test("tasks grouped by repository with header showing label and count", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/sidebar-repository-grouping-helpers.ts b/apps/web/e2e/tests/task/sidebar-repository-grouping-helpers.ts new file mode 100644 index 0000000000..8620a22798 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-repository-grouping-helpers.ts @@ -0,0 +1,33 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { ApiClient } from "../../helpers/api-client"; +import { makeGitEnv } from "../../helpers/git-helper"; + +export const SIDEBAR_COMBINATION_REPOSITORY_NAME = "Sidebar combination repo"; + +export async function seedSidebarCombinationRepository( + apiClient: Pick, + workspaceId: string, + tmpDir: string, +): Promise<{ id: string }> { + const repositoryPath = path.join(tmpDir, "repos", "sidebar-combination-repo"); + fs.mkdirSync(repositoryPath, { recursive: true }); + const gitEnv = makeGitEnv(tmpDir); + execSync("git init -b main", { cwd: repositoryPath, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: repositoryPath, env: gitEnv }); + return apiClient.createRepository(workspaceId, repositoryPath, "main", { + name: SIDEBAR_COMBINATION_REPOSITORY_NAME, + }); +} + +export async function repositoryName( + apiClient: Pick, + workspaceId: string, + repositoryId: string, +): Promise { + const { repositories } = await apiClient.listRepositories(workspaceId); + const repository = repositories.find((candidate) => candidate.id === repositoryId); + if (!repository) throw new Error(`Repository ${repositoryId} was not returned by the workspace`); + return repository.name; +} diff --git a/apps/web/lib/sidebar/apply-view.test.ts b/apps/web/lib/sidebar/apply-view.test.ts index ad191bde05..8c9be699f6 100644 --- a/apps/web/lib/sidebar/apply-view.test.ts +++ b/apps/web/lib/sidebar/apply-view.test.ts @@ -180,6 +180,25 @@ describe("applyFilters — repository (#1213)", () => { const out = applyFilters(tasks, [C({ dimension: "repository", op: "is", value: optionValue })]); expect(out.map((t) => t.id)).toEqual(["a"]); }); + + it("filters projected multi-repository tasks by their primary repository", () => { + const out = applyFilters( + [ + task({ + id: "multi", + repositoryPath: "org/primary", + repositories: ["org/primary", "org/secondary"], + repositoryLinks: [ + { repository_id: "repo-primary", position: 0 }, + { repository_id: "repo-secondary", position: 1 }, + ], + }), + ], + [C({ dimension: "repository", op: "is", value: "org/primary" })], + ); + + expect(out.map((item) => item.id)).toEqual(["multi"]); + }); }); describe("applyFilters — titleMatch + combos", () => { @@ -328,6 +347,147 @@ describe("applyGroup — state", () => { }); }); +describe("applyGroup — repository combinations", () => { + it("groups a projected multi-repository task by every repository slug", () => { + const repositories = ["owner/repo,one", "owner/repo-two"]; + const out = applyGroup( + [ + task({ + id: "multi", + repositories, + repositoryLinks: [ + { repository_id: "repo-one", position: 0 }, + { repository_id: "repo-two", position: 1 }, + ], + }), + ], + "repository", + ); + + expect(out.groups).toHaveLength(1); + expect(out.groups[0]).toMatchObject({ + key: `__repo_combination__:${JSON.stringify(repositories)}`, + label: "owner/repo,one, owner/repo-two", + }); + expect(out.groups[0].tasks.map((item) => item.id)).toEqual(["multi"]); + }); + + it("keeps equal combinations together and separates different ordered combinations", () => { + const first = ["owner/repo-a", "owner/repo-b"]; + const reversed = [...first].reverse(); + const out = applyGroup( + [ + task({ + id: "first", + repositories: first, + repositoryLinks: [ + { repository_id: "repo-a", position: 0 }, + { repository_id: "repo-b", position: 1 }, + ], + }), + task({ + id: "same", + repositories: [...first], + repositoryLinks: [ + { repository_id: "repo-a", position: 0 }, + { repository_id: "repo-b", position: 1 }, + ], + }), + task({ + id: "reversed", + repositories: reversed, + repositoryLinks: [ + { repository_id: "repo-b", position: 0 }, + { repository_id: "repo-a", position: 1 }, + ], + }), + ], + "repository", + ); + + expect(out.groups).toHaveLength(2); + expect(out.groups.map((group) => group.key)).toEqual([ + `__repo_combination__:${JSON.stringify(first)}`, + `__repo_combination__:${JSON.stringify(reversed)}`, + ]); + expect(out.groups[0].tasks.map((item) => item.id)).toEqual(["first", "same"]); + expect(out.groups[1].tasks.map((item) => item.id)).toEqual(["reversed"]); + }); + + it("keeps incomplete multi-repository metadata in the generic group", () => { + const out = applyGroup( + [ + task({ + id: "incomplete", + repositories: ["owner/repo-a"], + repositoryLinks: [ + { repository_id: "repo-a", position: 0 }, + { repository_id: "repo-missing", position: 1 }, + ], + }), + ], + "repository", + ); + + expect(out.groups).toHaveLength(1); + expect(out.groups[0].key).toBe("__multi__"); + expect(out.groups[0].label).toBe("Multi-repo"); + expect(out.groups[0].tasks.map((item) => item.id)).toEqual(["incomplete"]); + }); +}); + +describe("applyGroup — repository combination edge cases", () => { + it("accepts duplicate canonical slugs when each repository link resolves", () => { + const repositories = ["org/api", "org/api"]; + const out = applyGroup( + [ + task({ + id: "duplicate-slug", + repositories, + repositoryLinks: [ + { repository_id: "repo-local-api", position: 0 }, + { repository_id: "repo-remote-api", position: 1 }, + ], + }), + ], + "repository", + ); + + expect(out.groups).toHaveLength(1); + expect(out.groups[0]).toMatchObject({ + key: `__repo_combination__:${JSON.stringify(repositories)}`, + label: "org/api, org/api", + }); + }); + + it("sorts combination groups before single-repo groups and unassigned last", () => { + const repositories = ["org/one", "org/two"]; + const out = applyGroup( + [ + task({ id: "single", repositoryPath: "org/three" }), + task({ id: "single-other", repositoryPath: "org/four" }), + task({ id: "unassigned" }), + task({ + id: "combo", + repositories, + repositoryLinks: [ + { repository_id: "repo-one", position: 0 }, + { repository_id: "repo-two", position: 1 }, + ], + }), + ], + "repository", + ); + + expect(out.groups.map((group) => group.key)).toEqual([ + `__repo_combination__:${JSON.stringify(repositories)}`, + "org/four", + "org/three", + "__unassigned__", + ]); + }); +}); + describe("applyGroup", () => { it("wraps all tasks in single pseudo-group when group=none", () => { const tasks = [task({ id: "a" }), task({ id: "b" })]; @@ -360,6 +520,31 @@ describe("applyGroup", () => { expect(out.groups[0].tasks).toHaveLength(2); }); + it("does not merge unassigned into a named repository combination group", () => { + const repositories = ["org/one", "org/two"]; + const out = applyGroup( + [ + task({ + id: "multi", + repositories, + repositoryLinks: [ + { repository_id: "repo-one", position: 0 }, + { repository_id: "repo-two", position: 1 }, + ], + }), + task({ id: "unassigned" }), + ], + "repository", + ); + + expect(out.groups.map((group) => group.key)).toEqual([ + `__repo_combination__:${JSON.stringify(repositories)}`, + "__unassigned__", + ]); + expect(out.groups[0].tasks.map((item) => item.id)).toEqual(["multi"]); + expect(out.groups[1].tasks.map((item) => item.id)).toEqual(["unassigned"]); + }); + it("separates subtasks from root tasks", () => { const tasks = [ task({ id: "parent", repositoryPath: "r" }), diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index c1179a2597..18f667d1df 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -57,7 +57,9 @@ const dimensionExtractors: Record = { workflow: (t) => t.workflowId, workflowStep: (t) => t.workflowStepId, executorType: (t) => t.remoteExecutorType, - repository: (t) => (t.repositories && t.repositories.length > 1 ? "__multi__" : t.repositoryPath), + // Repository filters keep the primary compatibility value. Grouping uses + // the complete combination separately in `groupExtractors` below. + repository: (t) => t.repositoryPath, hasDiff: (t) => { const ds = t.diffStats; return !!ds && (ds.additions > 0 || ds.deletions > 0); @@ -203,8 +205,9 @@ export function applySort( * the mobile task-switcher sheet already carry `i18n.language` in their memo * deps for the same reason. * - * Only `label` is copy. The group `key`s below (`__multi__`, `__unassigned__`, - * `__all__`, `__not_started__`) are identity: they are compared in + * Only `label` is copy. The group `key`s below (`__multi__`, the + * `__repo_combination__:` keys, `__unassigned__`, `__all__`, + * `__not_started__`) are identity: they are compared in * `mergeSingleRepoUnassigned` / `sortRepoGroups`, index `STATE_GROUP_ORDER`, and * are persisted in the view's `collapsedGroups`. They are never translated. */ @@ -212,6 +215,7 @@ const UNASSIGNED_LABEL_KEY = "sidebar:groupUnassigned"; const MULTI_REPO_LABEL_KEY = "sidebar:groupMultiRepo"; const ALL_GROUP_LABEL_KEY = "sidebar:groupAll"; const NOT_STARTED_STATE_GROUP_KEY = "__not_started__"; +const REPOSITORY_COMBINATION_PREFIX = "__repo_combination__:"; const STATE_GROUP_ORDER: Record = { [NOT_STARTED_STATE_GROUP_KEY]: 0, @@ -229,6 +233,26 @@ const STATE_GROUP_ORDER: Record = { type GroupExtractor = (task: TaskSwitcherItem) => { key: string; label: string }; +function repositoryLinkIds(task: TaskSwitcherItem): string[] { + const ids = new Set(); + for (const link of task.repositoryLinks ?? []) ids.add(String(link.repository_id)); + return [...ids]; +} + +function isCompleteRepositoryCombination(task: TaskSwitcherItem): boolean { + const repositories = task.repositories ?? []; + const linkedRepositoryIds = repositoryLinkIds(task); + return repositories.length >= 2 && repositories.length === linkedRepositoryIds.length; +} + +function hasMultipleRepositoryLinks(task: TaskSwitcherItem): boolean { + return (task.repositories?.length ?? 0) > 1 || repositoryLinkIds(task).length > 1; +} + +function repositoryCombinationKey(repositories: string[]): string { + return `${REPOSITORY_COMBINATION_PREFIX}${JSON.stringify(repositories)}`; +} + function getTaskStateGroup(task: TaskSwitcherItem): { key: string; label: string } { if (!task.state) return { key: NOT_STARTED_STATE_GROUP_KEY, label: formatTaskStateLabel(undefined) }; @@ -286,7 +310,14 @@ const groupExtractors: Record, GroupExtractor> = { // copy, so they must not. Rename the parameter before adding a `t()` call to // any of the others. repository: (task) => { - if (task.repositories && task.repositories.length > 1) { + if (isCompleteRepositoryCombination(task)) { + const repositories = task.repositories!; + return { + key: repositoryCombinationKey(repositories), + label: repositories.join(", "), + }; + } + if (hasMultipleRepositoryLinks(task)) { return { key: "__multi__", label: t(MULTI_REPO_LABEL_KEY) }; } if (task.repositoryPath) return { key: task.repositoryPath, label: task.repositoryPath }; @@ -372,7 +403,12 @@ export function applyGroup( } function mergeSingleRepoUnassigned(groups: SidebarGroup[]): void { - const repoGroups = groups.filter((g) => g.key !== "__multi__" && g.key !== "__unassigned__"); + const repoGroups = groups.filter( + (g) => + g.key !== "__multi__" && + g.key !== "__unassigned__" && + !g.key.startsWith(REPOSITORY_COMBINATION_PREFIX), + ); if (repoGroups.length !== 1) return; const unassignedIdx = groups.findIndex((g) => g.key === "__unassigned__"); if (unassignedIdx === -1) return; @@ -385,6 +421,10 @@ function sortRepoGroups(groups: SidebarGroup[]): void { groups.sort((a, b) => { if (a.key === "__multi__") return -1; if (b.key === "__multi__") return 1; + const aIsCombination = a.key.startsWith(REPOSITORY_COMBINATION_PREFIX); + const bIsCombination = b.key.startsWith(REPOSITORY_COMBINATION_PREFIX); + if (aIsCombination && !bIsCombination && b.key !== "__unassigned__") return -1; + if (bIsCombination && !aIsCombination && a.key !== "__unassigned__") return 1; if (a.key === "__unassigned__") return 1; if (b.key === "__unassigned__") return -1; return a.label.localeCompare(b.label); diff --git a/apps/web/lib/sidebar/sidebar-task-repositories.test.ts b/apps/web/lib/sidebar/sidebar-task-repositories.test.ts new file mode 100644 index 0000000000..fbda363221 --- /dev/null +++ b/apps/web/lib/sidebar/sidebar-task-repositories.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + resolveTaskRepositorySlugs, + type SidebarTaskRepositoryLink, +} from "./sidebar-task-repositories"; + +const REPO_A = "repo-a"; +const REPO_B = "repo-b"; +const REPO_A_SLUG = "owner/repo-a"; +const REPO_B_SLUG = "owner/repo-b"; + +function link(repository_id: string, position?: number): SidebarTaskRepositoryLink { + return { repository_id, position }; +} + +describe("resolveTaskRepositorySlugs", () => { + it("returns empty for null, undefined, and empty input", () => { + const repositorySlugs = new Map([[REPO_A, REPO_A_SLUG]]); + + expect(resolveTaskRepositorySlugs(null, repositorySlugs)).toEqual([]); + expect(resolveTaskRepositorySlugs(undefined, repositorySlugs)).toEqual([]); + expect(resolveTaskRepositorySlugs([], repositorySlugs)).toEqual([]); + }); + + it("sorts by attachment position and removes duplicate repositories", () => { + const repositoryLinks = [link(REPO_B, 2), link(REPO_A, 1), link(REPO_A, 3), link("repo-c")]; + const repositorySlugs = new Map([ + [REPO_A, REPO_A_SLUG], + [REPO_B, REPO_B_SLUG], + ["repo-c", "owner/repo-c"], + ]); + + expect(resolveTaskRepositorySlugs(repositoryLinks, repositorySlugs)).toEqual([ + REPO_A_SLUG, + REPO_B_SLUG, + "owner/repo-c", + ]); + }); + + it("uses link order when positions are absent and omits unresolved repositories", () => { + const repositoryLinks = [link("repo-missing"), link(REPO_A), link(REPO_B)]; + const repositorySlugs = new Map([ + [REPO_A, REPO_A_SLUG], + [REPO_B, REPO_B_SLUG], + ]); + + expect(resolveTaskRepositorySlugs(repositoryLinks, repositorySlugs)).toEqual([ + REPO_A_SLUG, + REPO_B_SLUG, + ]); + }); +}); diff --git a/apps/web/lib/sidebar/sidebar-task-repositories.ts b/apps/web/lib/sidebar/sidebar-task-repositories.ts new file mode 100644 index 0000000000..4a64c32327 --- /dev/null +++ b/apps/web/lib/sidebar/sidebar-task-repositories.ts @@ -0,0 +1,32 @@ +export type SidebarTaskRepositoryLink = { + repository_id: string; + position?: number; +}; + +/** Resolve a task's repository links into the ordered slugs used by the sidebar. */ +export function resolveTaskRepositorySlugs( + repositoryLinks: readonly SidebarTaskRepositoryLink[] | null | undefined, + repositorySlugById: ReadonlyMap, +): string[] { + if (!repositoryLinks || repositoryLinks.length === 0) return []; + + const orderedLinks = repositoryLinks + .map((link, index) => ({ link, index })) + .sort((a, b) => { + const positionA = a.link.position ?? a.index; + const positionB = b.link.position ?? b.index; + return positionA - positionB || a.index - b.index; + }); + const seenRepositoryIds = new Set(); + const slugs: string[] = []; + + for (const { link } of orderedLinks) { + const repositoryId = String(link.repository_id); + if (seenRepositoryIds.has(repositoryId)) continue; + seenRepositoryIds.add(repositoryId); + const slug = repositorySlugById.get(repositoryId); + if (slug) slugs.push(slug); + } + + return slugs; +} diff --git a/docs/plans/sidebar-multi-repository-grouping/plan.md b/docs/plans/sidebar-multi-repository-grouping/plan.md new file mode 100644 index 0000000000..8712323ebd --- /dev/null +++ b/docs/plans/sidebar-multi-repository-grouping/plan.md @@ -0,0 +1,110 @@ +--- +created: 2026-08-27 +status: implemented +requirements: + - REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001 +system_design: + - ../../specs/ui/system-design/sidebar-repository-grouping.md +legacy_specs: [] +--- + +# Implementation Plan: Sidebar Multi-Repository Grouping + +## Overview + +Project every task repository into the shared sidebar item. Then group multi-repository tasks by their complete ordered repository combination. + +First, add the shared data correction and focused unit tests. Then add desktop and mobile browser evidence against a production build. + +## Confirmed root cause + +`buildSidebarItem` and `toSheetItem` project only the primary `repositoryId` into `repositoryPath`. Neither projector fills `TaskSwitcherItem.repositories`. + +`applyGroup` therefore treats a real multi-repository task as a single-repository task. It places the task in the primary repository group. + +The existing grouping unit test creates `TaskSwitcherItem.repositories` directly. This test does not pass through either production projector. + +The regression test is `groups a projected multi-repository task by every repository slug`. Before the correction, it receives only the primary slug. + +## Scope + +### In scope + +- Project ordered canonical repository slugs for desktop and mobile sidebar items. +- Keep repository links in both item projections for incomplete-metadata detection. +- Give each complete repository combination a stable key and concatenated label. +- Keep tasks visible while repository metadata resolves. +- Add focused unit tests and desktop/mobile Playwright coverage. + +### Out of scope + +- Repository filter semantics. +- Backend task or repository payload changes. +- Saved-view schema changes. +- Sidebar group-header layout changes. +- Public documentation changes. + +## Technical approach + +### Sidebar projection + +Add one shared helper for ordered repository slug resolution. Use it in `buildSidebarItem` and `toSheetItem`. + +The helper accepts task repository links and a repository-slug map. It sorts by `position`, removes duplicate repository IDs, and omits unresolved slugs. + +Both projectors set `repositories` from this helper. Both projectors keep `repositoryLinks` for the full attachment count. + +Repository filters continue to read the primary `repositoryPath` compatibility value. The complete slug array changes repository grouping only. + +### Repository grouping + +Update the repository extractor in `apply-view.ts`. Use a named combination when all repository links resolve to two or more slugs. + +Build the label with comma-space separators. Build the key with a fixed prefix and `JSON.stringify(repositories)`, including repeated canonical slugs when distinct links resolve to the same slug. + +Keep `__multi__` only as the incomplete-metadata fallback. Update repository-group sorting to recognize named combination keys. + +## Mobile design contract + +- **Outcome and entry:** Desktop uses the Tasks sidebar. Phones use the existing task-switcher drawer. +- **Exemplar:** Keep `SessionTaskSwitcherSheet` and its shared `TaskSwitcher` list. +- **Hierarchy and presentation:** The existing group heading shows the complete repository combination. No new surface is added. +- **Interaction:** The existing group heading remains the collapse and expand control. +- **Scroll and geometry:** Existing desktop and mobile scroll owners remain unchanged. +- **Shared state:** Both surfaces use the same group identity and label rules. +- **Mobile proof:** A Pixel 5 Playwright test shows the full group heading and its task. + +## Tests + +- `task-session-sidebar-item.test.ts` proves that desktop projection keeps all ordered slugs. +- `session-task-switcher-sheet-item.test.ts` proves the same mobile projection. +- `sidebar-task-repositories.test.ts` proves order, duplicate removal, and unresolved metadata behavior. +- `apply-view.test.ts` proves complete combination keys, labels, separation, and fallback behavior. +- `apply-view-labels.test.ts` keeps the generic label only for incomplete metadata. + +## E2E tests + +- `sidebar-layout.spec.ts` covers `AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.2`, `.3`, and `.5` on desktop. +- `mobile-sidebar-views.spec.ts` covers `AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.6` on mobile. +- Focused unit tests cover single-repository compatibility, combination identity, and incomplete metadata recovery. + +## Work orders + +- [x] [Task 01: Correct repository projection and grouping](task-01-correct-repository-grouping.md) +- [x] [Task 02: Prove desktop and mobile grouping](task-02-prove-responsive-grouping.md) + +## Verification results + +- `cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/sidebar-task-repositories.test.ts components/task/task-session-sidebar-item.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts components/task/mobile/session-task-switcher-sheet-hooks.test.ts lib/sidebar/apply-view.test.ts lib/sidebar/apply-view-labels.test.ts`: passed, 6 files and 111 tests. +- `cd apps/web && pnpm run typecheck`: passed. +- `cd apps/web && pnpm run lint`: passed with zero warnings. +- `cd apps/web && pnpm run i18n:check`: passed. +- `cd apps/web && pnpm e2e:run tests/task/sidebar-layout.spec.ts -- --grep "multi-repository group"`: passed, 1 desktop test. +- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "multi-repository group"`: passed, 1 mobile test. + +## Risks + +- Repository names can contain the display separator. The internal key must not use the display label as its encoding. +- Desktop and mobile have separate item projectors. Both must implement the same contract. +- Repository hydration can be incomplete. The task must not fall back to its primary repository group. +- A new combination key means that the old generic collapsed state does not collapse named combination groups. diff --git a/docs/plans/sidebar-multi-repository-grouping/task-01-correct-repository-grouping.md b/docs/plans/sidebar-multi-repository-grouping/task-01-correct-repository-grouping.md new file mode 100644 index 0000000000..b5d123932b --- /dev/null +++ b/docs/plans/sidebar-multi-repository-grouping/task-01-correct-repository-grouping.md @@ -0,0 +1,103 @@ +--- +id: "01-correct-repository-grouping" +title: "Correct repository projection and grouping" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001 +acceptance_criteria: + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.1 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.2 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.3 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.4 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.5 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.7 +system_design: + - ../../specs/ui/system-design/sidebar-repository-grouping.md +--- + +# Task 01: Correct Repository Projection and Grouping + +## Summary + +Project every task repository into desktop and mobile sidebar items. Use the complete ordered slug list for repository group identity and labels. + +## In scope + +- Add a shared ordered repository-slug helper. +- Update both production sidebar item projectors. +- Add stable named combination keys and labels. +- Keep the generic multi-repository fallback for incomplete metadata. +- Add focused unit tests before production changes. +- Start with the red test `groups a projected multi-repository task by every repository slug`. + +## Out of scope + +- Playwright coverage. +- Repository filter membership changes. +- Saved-view persistence changes. +- Group-header component changes. + +## Acceptance + +- A projected two-repository task groups under both canonical slugs in attachment order. +- Different ordered combinations use different keys, and equal combinations share one key. +- Incomplete repository metadata keeps the task in a nonempty generic multi-repository group. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/sidebar-task-repositories.test.ts components/task/task-session-sidebar-item.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts lib/sidebar/apply-view.test.ts lib/sidebar/apply-view-labels.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +``` + +## Files likely touched + +- `apps/web/lib/sidebar/sidebar-task-repositories.ts` +- `apps/web/lib/sidebar/sidebar-task-repositories.test.ts` +- `apps/web/components/task/task-switcher-types.ts` +- `apps/web/components/task/task-session-sidebar-item.ts` +- `apps/web/components/task/task-session-sidebar-item.test.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-item.ts` +- `apps/web/components/task/mobile/session-task-switcher-sheet-item.test.ts` +- `apps/web/lib/sidebar/apply-view.ts` +- `apps/web/lib/sidebar/apply-view.test.ts` +- `apps/web/lib/sidebar/apply-view-labels.test.ts` + +## Dependencies + +None. + +## Risks + +- Do not use the display label as the internal key. +- Do not remove the generic label while incomplete metadata can occur. +- Keep single-repository keys unchanged. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001` +- `docs/specs/ui/system-design/sidebar-repository-grouping.md` +- Existing `repositorySlug` and sidebar projection patterns. + +## Results + +RED: the initial projection/grouping regression run failed with five assertions. Both sidebar projectors returned no `repositories`, complete combinations were classified as the generic `__multi__` group, equal and reversed combinations were not separated, and incomplete metadata could fall into `__unassigned__`. + +GREEN: added the shared ordered slug resolver, projected `repositories` and retained `repositoryLinks` in desktop and mobile items, and added collision-safe ordered-combination keys with a generic hydration fallback. The final focused run passed 6 files and 111 tests. TypeScript passed, and web lint passed with zero warnings. Repository filters continue to use the primary compatibility value, and complete combinations retain duplicate canonical slugs. + +Verification: + +```text +cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/sidebar-task-repositories.test.ts components/task/task-session-sidebar-item.test.ts components/task/mobile/session-task-switcher-sheet-item.test.ts components/task/mobile/session-task-switcher-sheet-hooks.test.ts lib/sidebar/apply-view.test.ts lib/sidebar/apply-view-labels.test.ts # 6 files, 111 tests passed +cd apps/web && pnpm run typecheck # passed +cd apps/web && pnpm run lint # passed with zero warnings +cd apps/web && pnpm run i18n:check # passed +``` diff --git a/docs/plans/sidebar-multi-repository-grouping/task-02-prove-responsive-grouping.md b/docs/plans/sidebar-multi-repository-grouping/task-02-prove-responsive-grouping.md new file mode 100644 index 0000000000..6b454aadd8 --- /dev/null +++ b/docs/plans/sidebar-multi-repository-grouping/task-02-prove-responsive-grouping.md @@ -0,0 +1,86 @@ +--- +id: "02-prove-responsive-grouping" +title: "Prove desktop and mobile grouping" +status: done +wave: 2 +depends_on: + - "01-correct-repository-grouping" +plan: "plan.md" +requirements: + - REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001 +acceptance_criteria: + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.2 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.3 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.5 + - AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.6 +system_design: + - ../../specs/ui/system-design/sidebar-repository-grouping.md +--- + +# Task 02: Prove Desktop and Mobile Grouping + +## Summary + +Add production-build browser coverage for the complete multi-repository group label. Cover the desktop sidebar and the mobile task-switcher drawer. + +## In scope + +- Seed a second repository in an isolated E2E workspace. +- Create a task with both repositories in a defined order. +- Assert the complete group label and the task location on desktop. +- Assert the same user outcome in the Pixel 5 mobile project. + +## Out of scope + +- New page objects without reuse value. +- New responsive controls or layout changes. +- Full E2E suite execution. + +## Acceptance + +- The desktop sidebar shows the complete ordered label and contains the multi-repository task. +- The mobile drawer shows the same label and task. +- Neither surface shows the task in the primary repository-only group. + +## Verification + +```bash +cd apps/web && pnpm e2e:run tests/task/sidebar-layout.spec.ts -- --grep "multi-repository group" +cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "multi-repository group" +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/sidebar-layout.spec.ts` +- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts` +- `apps/web/e2e/tests/task/sidebar-repository-grouping-helpers.ts` + +## Dependencies + +Task 01. + +## Risks + +- The second repository needs a valid local Git repository in the worker temporary directory. +- The mobile command must select the `mobile-chrome` project before the test path. + +## Parallelism + +`sequential` + +## Inputs + +- Task 01 result. +- `apps/web/e2e/helpers/api-client.ts` repository and task seed methods. +- Existing sidebar layout and mobile sidebar view tests. + +## Results + +The production-build desktop regression passed with 1 test. The mobile-chrome regression passed with 1 test using the existing task-switcher drawer. Both assertions found the complete ordered combination label and the task under that group, and confirmed the task was absent from the primary repository-only group. + +Verification: + +```text +cd apps/web && pnpm e2e:run tests/task/sidebar-layout.spec.ts -- --grep "multi-repository group" # 1 passed +cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "multi-repository group" # 1 passed +``` diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 1a8a669661..0801d92bb8 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -121,6 +121,7 @@ contract that other capabilities can reuse. - [Sidebar empty task alignment](requirements/sidebar-empty-task-alignment.md) - [Sidebar Last Activity Sort](requirements/sidebar-last-activity-sort.md) - [Sidebar Queued Prompt Count Badge](requirements/sidebar-queued-prompt-count.md) +- [Repository Groups](requirements/sidebar-repository-grouping.md) - [Sidebar Task Focus](requirements/sidebar-task-focus.md) - [Sidebar Task Completion Icons](requirements/sidebar-task-completion-icons.md) - [Sidebar Task Row Presentation](requirements/sidebar-task-row-presentation.md) @@ -165,6 +166,7 @@ contract that other capabilities can reuse. - [Kandev MCP Tool Results](system-design/kandev-mcp-tool-results.md) - [Mobile Task Chrome](system-design/mobile-task-chrome.md) - [Persistent status motion](system-design/persistent-status-motion.md) +- [Repository Groups](system-design/sidebar-repository-grouping.md) - [Sidebar Task Focus](system-design/sidebar-task-focus.md) - [PR Task Status Summary](system-design/pr-task-status-summary.md) - [Prompt History Panel](system-design/prompt-history-panel.md) diff --git a/docs/specs/ui/requirements/sidebar-repository-grouping.md b/docs/specs/ui/requirements/sidebar-repository-grouping.md new file mode 100644 index 0000000000..e495c7a5ef --- /dev/null +++ b/docs/specs/ui/requirements/sidebar-repository-grouping.md @@ -0,0 +1,46 @@ +--- +status: active +system: ui +created: 2026-08-27 +owners: + - kandev +--- + +# Sidebar Repository Grouping Requirements + +## Overview + +Saved sidebar views can group tasks by repository. The group heading must show the complete repository context for each task. + +The UI system owns this presentation contract. The task system supplies the ordered repository links but does not define sidebar groups. + +## Terminology + +- **Repository slug:** The canonical user-facing repository name from the workspace repository record. +- **Repository combination:** The ordered repository slugs that belong to one task. + +## Requirements + +### REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001: Complete Repository Grouping + +**Intent:** Users can identify the full repository scope of each task when a sidebar view groups tasks by repository. + +**User story:** As a user, I want multi-repository tasks in named combination groups, so that I can see their complete scope. + +#### Acceptance criteria + +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.1:** When a task has one repository, its group heading shall show the canonical repository slug. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.2:** When a task has multiple repositories, its group heading shall join all canonical slugs with comma-space separators. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.3:** The heading shall keep the repository attachment order, with the primary repository first. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.4:** Tasks with the same ordered combination shall share one group. Different combinations shall use different groups. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.5:** A multi-repository task shall not appear in a group for only its primary repository. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.6:** Desktop and mobile task sidebars shall show the same group identity and heading. +- **AC-UI-SIDEBAR-REPOSITORY-GROUPING-001.7:** When repository metadata is incomplete, the task shall remain visible in a nonempty multi-repository group. The named group shall replace it after metadata resolves. + +## Out of scope + +- Changes to repository filter membership. +- Changes to repository attachment order. +- Changes to saved-view persistence or group-collapse persistence. +- Changes to group-header layout, truncation, or touch behavior. + diff --git a/docs/specs/ui/system-design/sidebar-repository-grouping.md b/docs/specs/ui/system-design/sidebar-repository-grouping.md new file mode 100644 index 0000000000..12602a7a7f --- /dev/null +++ b/docs/specs/ui/system-design/sidebar-repository-grouping.md @@ -0,0 +1,79 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001 +--- + +# Sidebar Repository Grouping System Design + +## Purpose and boundaries + +The task store supplies ordered repository links. The workspace store supplies repository records for user-facing slugs. + +The sidebar projection combines these sources before `applyView` groups tasks. This design changes no backend API or saved-view schema. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-UI-SIDEBAR-REPOSITORY-GROUPING-001` | [Projection contract](#projection-contract), [Group identity](#group-identity), [Responsive behavior](#responsive-behavior) | + +## Components and responsibilities + +- `repositorySlug` supplies one canonical slug for each workspace repository. +- `buildSidebarItem` projects repository slugs for the desktop sidebar. +- `toSheetItem` projects the same slugs for the mobile task-switcher surface. +- `TaskSwitcherItem` carries the ordered repository slugs and the persisted repository links. +- `applyGroup` creates repository group keys and labels from the projected slugs. +- `TaskSwitcher` renders the same grouped model on desktop and mobile. + +## Projection contract + +Each sidebar projector resolves every task repository through the workspace repository map. The projector keeps the task link order. + +The projector writes the resolved slugs to `TaskSwitcherItem.repositories`. It also keeps `repositoryLinks` so grouping can detect incomplete metadata. + +The existing `repositoryPath` remains the single-repository and compatibility value. Repository filters continue to use this primary value. A pull-request summary can continue to supply it. + +## Group identity + +For one repository, `applyGroup` keeps the canonical slug as the key and label. This behavior preserves existing single-repository groups. + +For a complete repository combination, the label uses `repositories.join(", ")`. The internal key uses a fixed prefix and a JSON array encoding. Distinct links can retain the same canonical slug, so the ordered array is not deduplicated by display value. + +This encoding prevents collisions when a repository slug contains a separator. It also keeps distinct ordered combinations in distinct groups. + +Multi-repository combination groups stay before single-repository groups. Combination groups sort by their display labels. + +When metadata is incomplete, `repositoryLinks.length` remains greater than the resolved slug count. The task uses the existing generic multi-repository group. + +After repository hydration, the projectors recompute the item. Then `applyView` moves the task to its named combination group. + +## Responsive behavior + +Desktop and mobile keep their current sidebar containers, scroll owners, and controls. Both surfaces pass the same `TaskSwitcherItem` contract to `applyView`. + +The mobile exemplar is `SessionTaskSwitcherSheet`. This fix changes shared data normalization and does not change mobile composition or touch behavior. + +## Tests + +Projection tests cover desktop and mobile mappings. They use two ordered task links and two workspace repository records. + +Grouping tests cover complete combinations, different combinations, incomplete metadata, and single-repository compatibility. + +Desktop and mobile Playwright tests seed one task with two repositories. Each test asserts the complete heading and the task location. + +## Failure and recovery + +An unknown repository record does not hide the task or show an empty heading. The generic multi-repository group remains until metadata resolves. + +## Persistence + +This design adds no persisted field. Existing collapsed keys for the generic multi-repository group do not apply to named combination groups. + +The new combination key is deterministic for the same ordered slugs. Existing single-repository collapsed keys remain unchanged. + +## Security + +The UI uses repository names that the workspace repository store already exposes. This design adds no new data source or permission boundary.