fix(ui): group sidebar tasks by repository combination (#3091)

This commit is contained in:
Carlos Florêncio
2026-08-27 14:49:42 +00:00
committed by GitHub
parent d4f029f71d
commit a5a15f12ea
18 changed files with 1005 additions and 7 deletions
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { toSheetItem } from "./session-task-switcher-sheet-item";
type SheetTask = Parameters<typeof toSheetItem>[0];
type SheetCtx = Parameters<typeof toSheetItem>[1];
function task(overrides: Partial<SheetTask> = {}): 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);
});
});
@@ -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),
};
+3 -2
View File
@@ -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;
/**
@@ -27,7 +27,40 @@ function task(overrides: Partial<SidebarTask> = {}): 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({
@@ -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<string, string | undefined>;
@@ -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,
@@ -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,
@@ -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,
@@ -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<ApiClient, "createRepository">,
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<ApiClient, "listRepositories">,
workspaceId: string,
repositoryId: string,
): Promise<string> {
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;
}
+185
View File
@@ -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" }),
+45 -5
View File
@@ -57,7 +57,9 @@ const dimensionExtractors: Record<FilterDimension, DimensionExtractor> = {
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__:<json>` 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<string, number> = {
[NOT_STARTED_STATE_GROUP_KEY]: 0,
@@ -229,6 +233,26 @@ const STATE_GROUP_ORDER: Record<string, number> = {
type GroupExtractor = (task: TaskSwitcherItem) => { key: string; label: string };
function repositoryLinkIds(task: TaskSwitcherItem): string[] {
const ids = new Set<string>();
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<Exclude<GroupKey, "none">, 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);
@@ -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,
]);
});
});
@@ -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, string | undefined>,
): 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<string>();
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;
}
@@ -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.
@@ -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
```
@@ -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
```
+2
View File
@@ -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)
@@ -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.
@@ -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.