fix(ui): group sidebar tasks by repository combination (#3091)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user