feat(web): name each task's repository in the sidebar and task header (#2917)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-22 13:58:07 +01:00
committed by GitHub
parent 5a9d4c534e
commit 9ef80367f8
21 changed files with 505 additions and 37 deletions
+7 -2
View File
@@ -270,9 +270,14 @@ function ParentCrumbLabel({ crumb }: { crumb: ParentCrumb }) {
</>
);
}
// Static crumb: orientation only, dimmed so it does not read as a link.
// Static crumb: orientation only, dimmed so it does not read as a link. The
// `title` is what makes a name longer than `max-w-40` readable at all, since
// truncation is the only thing standing between a long label and a squeezed
// page title.
return (
<span className="max-w-40 cursor-default truncate text-muted-foreground/60">{crumb.label}</span>
<span title={crumb.label} className="max-w-40 cursor-default truncate text-muted-foreground/60">
{crumb.label}
</span>
);
}
@@ -80,6 +80,8 @@ type SessionMobileLayoutProps = {
baseBranch?: string;
worktreeBranch?: string | null;
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
isRemoteExecutor?: boolean;
remoteExecutorType?: string | null;
remoteExecutorName?: string | null;
@@ -352,6 +354,8 @@ type MobileTopBarStickyProps = {
activeTaskId: string | null;
workspaceId: string | null;
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
effectiveSessionId: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
@@ -378,6 +382,7 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) {
taskId={props.activeTaskId}
workspaceId={props.workspaceId}
taskTitle={props.taskTitle}
repositoryLabel={props.repositoryLabel}
sessionId={props.effectiveSessionId}
baseBranch={props.baseBranch}
worktreeBranch={props.worktreeBranch}
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
afterEach(cleanup);
// The phone bar's git machinery is not what this file is about; stub the two
// hooks that reach for session state so the header renders standalone.
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => ({ files: [] }),
useSessionGitStatusByRepo: () => [],
}));
vi.mock("@/hooks/domains/session/use-session-commits", () => ({
useSessionCommits: () => ({ commits: [] }),
}));
vi.mock("@/hooks/domains/session/use-remote-contribution-relation", () => ({
useRemoteContributionRelation: () => ({
relation: { action: "none", canPull: false, canReplaceRemote: false, canUseRemote: false },
}),
}));
// Trailing controls the header composes but this file does not exercise.
vi.mock("@/components/task/port-forward-dialog", () => ({
PortForwardButton: () => null,
}));
vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({
TaskTopBarPluginActions: () => null,
}));
vi.mock("@/components/gitlab/mr-topbar-button", () => ({
MRTopbarButton: () => null,
}));
const REPOSITORY_TEST_ID = "mobile-task-repository";
function renderTopBar(props: Record<string, unknown> = {}) {
return render(
<StateProvider>
<ToastProvider>
<TooltipProvider>
<SessionMobileTopBar
taskId="task-1"
workspaceId="ws-1"
taskTitle="Pin the RDS engine version"
sessionId="session-1"
onMenuClick={vi.fn()}
{...props}
/>
</TooltipProvider>
</ToastProvider>
</StateProvider>,
);
}
describe("SessionMobileTopBar repository", () => {
it("names the task's repository, so the phone header says which project this is", () => {
renderTopBar({ repositoryLabel: "kdlbs/kandev" });
expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe("kdlbs/kandev");
});
it("keeps the full repository name reachable on hover when it is truncated", () => {
renderTopBar({ repositoryLabel: "acme-platform/infra-terraform-modules" });
expect(screen.getByTestId(REPOSITORY_TEST_ID).getAttribute("title")).toBe(
"acme-platform/infra-terraform-modules",
);
});
it("renders nothing when the task has no repository", () => {
renderTopBar();
expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull();
});
});
@@ -38,6 +38,8 @@ type SessionMobileTopBarProps = {
taskId?: string | null;
workspaceId?: string | null;
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
sessionId?: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
@@ -56,11 +58,13 @@ type SessionMobileTopBarProps = {
function MobileTaskTitle({
taskTitle,
repositoryLabel,
displayBranch,
totalAdditions,
totalDeletions,
}: {
taskTitle?: string;
repositoryLabel?: string | null;
displayBranch?: string;
totalAdditions: number;
totalDeletions: number;
@@ -69,15 +73,32 @@ function MobileTaskTitle({
return (
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{taskTitle ?? t("task:taskDetails")}</span>
{displayBranch && (
<div className="flex items-center gap-1.5">
<IconGitBranch className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs text-muted-foreground truncate">{displayBranch}</span>
{(totalAdditions > 0 || totalDeletions > 0) && (
<LineStat added={totalAdditions} removed={totalDeletions} />
)}
</div>
)}
{/* The phone bar has no breadcrumb, so the repository rides the same
secondary line as the branch. It shrinks first: on a phone the branch
and diff stats are the denser signal, and the full name stays in
`title`. */}
<div className="flex items-center gap-1.5 min-w-0">
{repositoryLabel && (
<span
data-testid="mobile-task-repository"
title={repositoryLabel}
className="min-w-0 truncate text-xs text-muted-foreground/70"
>
{repositoryLabel}
</span>
)}
{displayBranch && (
<>
<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="shrink-0 max-w-[45%] truncate text-xs text-muted-foreground">
{displayBranch}
</span>
{(totalAdditions > 0 || totalDeletions > 0) && (
<LineStat added={totalAdditions} removed={totalDeletions} />
)}
</>
)}
</div>
</div>
);
}
@@ -494,6 +515,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
</Button>
<MobileTaskTitle
taskTitle={props.taskTitle}
repositoryLabel={props.repositoryLabel}
displayBranch={displayBranch}
totalAdditions={totalAdditions}
totalDeletions={totalDeletions}
@@ -8,7 +8,12 @@ type TaskArchivedState = {
isArchived: boolean;
archivedTaskId?: string;
archivedTaskTitle?: string;
archivedTaskRepositoryPath?: string;
/**
* The archived task's repository as a stable `owner/repo` slug (or name).
* Sidebar rows render this, so it must be the same identity ordinary rows
* carry, never a local clone path.
*/
archivedTaskRepositoryLabel?: string;
archivedTaskUpdatedAt?: string;
};
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { StateProvider } from "@/components/state-provider";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { TaskItem } from "./task-item";
afterEach(() => cleanup());
const REPOSITORY_TEST_ID = "sidebar-task-repository";
const PRIMARY_REPOSITORY = "kdlbs/kandev";
function renderTaskItem(props: Partial<ComponentProps<typeof TaskItem>> = {}) {
return render(
<StateProvider>
<TooltipProvider>
<TaskItem title="Needs answer" state="REVIEW" {...props} />
</TooltipProvider>
</StateProvider>,
);
}
describe("TaskItem repository label", () => {
it("names the repository the task belongs to", () => {
renderTaskItem({ repositoryPath: PRIMARY_REPOSITORY });
expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(PRIMARY_REPOSITORY);
});
it("keeps the full repository name reachable on hover when it is truncated", () => {
renderTaskItem({ repositoryPath: "a-very-long-org-name/a-very-long-repository-name" });
expect(screen.getByTestId(REPOSITORY_TEST_ID).getAttribute("title")).toBe(
"a-very-long-org-name/a-very-long-repository-name",
);
});
it("omits the repository when the list already groups by it", () => {
renderTaskItem({ repositoryPath: PRIMARY_REPOSITORY, showRepository: false });
expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull();
});
it("renders nothing when the task has no repository", () => {
renderTaskItem({});
expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull();
});
});
@@ -25,18 +25,39 @@ const POLL_MODE_CONFIG: Record<
};
/**
* The sidebar row's metadata line: relative last-update time, PR number,
* The repository a task belongs to. The one element of the stats row allowed to
* shrink: a long `owner/repo` truncates rather than pushing the PR number and
* badges out of the row, and `title` keeps the full name reachable on hover.
*/
function RepositoryLabel({ label }: { label?: string }) {
if (!label) return null;
return (
<span
data-testid="sidebar-task-repository"
title={label}
className="min-w-0 truncate text-muted-foreground/50"
>
{label}
</span>
);
}
/**
* The sidebar row's metadata line: relative last-update time, repository, PR number,
* queued-prompt mail badge, WIP queue icon, and (debug UI only) the session
* poll-mode letter.
*/
export function TaskItemStatsRow({
updatedAt,
repositoryLabel,
prInfo,
primarySessionId,
queuedCount,
wipQueue,
}: {
updatedAt?: string;
/** Repository the task belongs to; already resolved to a slug or name. */
repositoryLabel?: string;
prInfo?: { number: number; state: string; aggregateState?: string };
primarySessionId?: string | null;
queuedCount?: number;
@@ -49,7 +70,9 @@ export function TaskItemStatsRow({
: null,
);
if (!updatedAt && !prInfo && !pollMode && !queuedCount && !wipQueue) return null;
if (!updatedAt && !repositoryLabel && !prInfo && !pollMode && !queuedCount && !wipQueue) {
return null;
}
const modeConfig = pollMode ? POLL_MODE_CONFIG[pollMode] : null;
const modeLabel = modeConfig ? t(modeConfig.labelKey) : "";
@@ -62,17 +85,18 @@ export function TaskItemStatsRow({
: "";
return (
<span className="flex items-center gap-1.5 text-[11px]">
<span className="flex min-w-0 items-center gap-1.5 text-[11px]">
{updatedAt && (
<span
data-testid="sidebar-task-time"
data-time-value={updatedAt}
className="text-muted-foreground/50"
className="shrink-0 text-muted-foreground/50"
>
{formatRelativeTime(updatedAt)}
</span>
)}
{prInfo && <span className="text-muted-foreground/50">#{prInfo.number}</span>}
<RepositoryLabel label={repositoryLabel} />
{prInfo && <span className="shrink-0 text-muted-foreground/50">#{prInfo.number}</span>}
{queuedCount ? (
<span
data-testid="sidebar-task-queued-count"
+20 -10
View File
@@ -95,7 +95,17 @@ type TaskItemProps = {
subtasksCollapsed?: boolean;
/** Toggles subtask visibility when the chevron is clicked. */
onToggleSubtasks?: () => void;
repositories?: string[];
/**
* 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.
*/
repositoryPath?: string;
/**
* Whether this row should name its repository. False when the list is grouped
* by repository, where the section header already says it.
*/
showRepository?: boolean;
prInfo?: { number: number; state: string; aggregateState?: string };
/** Number of prompts currently en-queued for this task (mail badge). */
queuedCount?: number;
@@ -341,7 +351,8 @@ function TaskItemContent({
primarySessionId,
isArchived,
isPinned,
repositories,
repositoryPath,
showRepository,
updatedAt,
lastActivityAt,
showActivityTime,
@@ -362,7 +373,8 @@ function TaskItemContent({
primarySessionId?: string | null;
isArchived?: boolean;
isPinned?: boolean;
repositories?: string[];
repositoryPath?: string;
showRepository: boolean;
updatedAt?: string;
lastActivityAt?: string;
showActivityTime?: boolean;
@@ -402,11 +414,6 @@ function TaskItemContent({
</span>
)}
</span>
{repositories && repositories.length > 1 && (
<span className="truncate text-[11px] text-muted-foreground/50">
{repositories.join(" · ")}
</span>
)}
{taskId && (
<TaskRowMetadata
taskId={taskId}
@@ -416,6 +423,7 @@ function TaskItemContent({
)}
<TaskItemStatsRow
updatedAt={showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt}
repositoryLabel={showRepository ? repositoryPath : undefined}
prInfo={prInfo}
primarySessionId={primarySessionId}
queuedCount={queuedCount}
@@ -461,7 +469,8 @@ export const TaskItem = memo(function TaskItem({
subtaskCount,
subtasksCollapsed,
onToggleSubtasks,
repositories,
repositoryPath,
showRepository = true,
prInfo,
queuedCount,
wipQueue,
@@ -510,7 +519,8 @@ export const TaskItem = memo(function TaskItem({
primarySessionId={primarySessionId}
isArchived={isArchived}
isPinned={isPinned}
repositories={repositories}
repositoryPath={repositoryPath}
showRepository={showRepository}
updatedAt={updatedAt}
lastActivityAt={lastActivityAt}
showActivityTime={showActivityTime}
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { TaskLayout } from "./task-layout";
afterEach(cleanup);
// Every hop between the task page and the phone header is an optional prop, so
// a dropped one is not a type error. This asserts the hop TaskLayout owns: the
// repository label reaches the phone layout. `session-mobile-top-bar-repository`
// covers what the header does with it.
vi.mock("./mobile", () => ({
SessionMobileLayout: ({ repositoryLabel }: { repositoryLabel?: string | null }) => (
<div data-testid="mobile-layout" data-repository-label={repositoryLabel ?? ""} />
),
SessionTabletLayout: () => <div data-testid="tablet-layout" />,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true, usesDesktopWorkbench: false }),
}));
vi.mock("./task-launch-error-context", () => ({
useTaskLaunchErrorContext: () => ({ error: null, repositories: [] }),
}));
describe("TaskLayout repository label", () => {
it("hands the repository label to the phone layout", () => {
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" repositoryLabel="kdlbs/kandev" />);
expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe(
"kdlbs/kandev",
);
});
it("passes nothing along for a task with no repository", () => {
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />);
expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe("");
});
});
+4
View File
@@ -29,6 +29,8 @@ type TaskLayoutProps = {
initialTerminals?: Terminal[];
defaultLayouts?: Record<string, Layout>;
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
isRemoteExecutor?: boolean;
@@ -51,6 +53,7 @@ export const TaskLayout = memo(function TaskLayout({
initialTerminals,
defaultLayouts = {},
taskTitle,
repositoryLabel,
baseBranch,
worktreeBranch,
isRemoteExecutor,
@@ -94,6 +97,7 @@ export const TaskLayout = memo(function TaskLayout({
baseBranch={baseBranch}
worktreeBranch={worktreeBranch}
taskTitle={taskTitle}
repositoryLabel={repositoryLabel}
isRemoteExecutor={isRemoteExecutor}
remoteExecutorType={remoteExecutorType}
remoteExecutorName={remoteExecutorName}
@@ -3,6 +3,7 @@ import {
taskId as toTaskId,
workflowId as toWorkflowId,
workspaceId as toWorkspaceId,
type Repository,
type Task,
} from "@/lib/types/http";
import type { KanbanState } from "@/lib/state/slices";
@@ -108,6 +109,75 @@ describe("resolveTaskProps", () => {
expect(props.issueUrl).toBe("https://github.com/kdlbs/kandev/issues/1470");
expect(props.issueNumber).toBe(1470);
});
it("labels the repository by its provider slug, not its local clone path", () => {
const props = resolveTaskProps(
{ id: "task-1", title: "Any" } as unknown as Task,
{
id: "repo-1",
name: "kandev",
local_path: "/home/dev/src/kandev",
provider_owner: "kdlbs",
provider_name: "kandev",
} as unknown as Repository,
);
expect(props.repositoryLabel).toBe("kdlbs/kandev");
});
it("falls back to the repository name when no provider owns it", () => {
const props = resolveTaskProps(
{ id: "task-1", title: "Any" } as unknown as Task,
{
id: "repo-1",
name: "scratchpad",
local_path: "/home/dev/src/scratchpad",
} as unknown as Repository,
);
expect(props.repositoryLabel).toBe("scratchpad");
});
it("has no repository label for a task with no repository", () => {
const props = resolveTaskProps({ id: "task-1", title: "Any" } as unknown as Task, null);
expect(props.repositoryLabel).toBeNull();
});
});
describe("buildArchivedValue repository identity", () => {
// The archived row renders this value, so a local clone path here would put
// "/home/dev/src/kandev" in the sidebar and give archived tasks a different
// repository grouping key from every ordinary task.
it("carries the provider slug, not the local clone path", () => {
const value = buildArchivedValue(
{ id: "task-1", title: "Any", archived_at: ARCHIVED_AT } as unknown as Task,
{
id: "repo-1",
name: "kandev",
local_path: "/home/dev/src/kandev",
provider_owner: "kdlbs",
provider_name: "kandev",
} as unknown as Repository,
);
expect(value.archivedTaskRepositoryLabel).toBe("kdlbs/kandev");
});
it("leaves the label unset for a task that is not archived", () => {
const value = buildArchivedValue(
{ id: "task-1", title: "Any" } as unknown as Task,
{
id: "repo-1",
name: "kandev",
local_path: "/home/dev/src/kandev",
provider_owner: "kdlbs",
provider_name: "kandev",
} as unknown as Repository,
);
expect(value.archivedTaskRepositoryLabel).toBeUndefined();
});
});
describe("resolveTaskContentState", () => {
@@ -7,6 +7,7 @@ import {
} from "@/lib/types/http";
import type { KanbanState } from "@/lib/state/slices";
import { issueFieldsFromMetadata } from "@/lib/metadata-utils";
import { repositorySlug } from "@/lib/repository-slug";
type ACPDebugInfo = {
sessionId: unknown;
@@ -169,7 +170,7 @@ export function buildArchivedValue(task: Task | null, repository: Repository | n
isArchived,
archivedTaskId: isArchived ? task?.id : undefined,
archivedTaskTitle: isArchived ? task?.title : undefined,
archivedTaskRepositoryPath: isArchived ? (repository?.local_path ?? undefined) : undefined,
archivedTaskRepositoryLabel: isArchived && repository ? repositorySlug(repository) : undefined,
archivedTaskUpdatedAt: isArchived ? task?.updated_at : undefined,
};
}
@@ -238,6 +239,12 @@ export function resolveTaskProps(task: Task | null, repository: Repository | nul
issueNumber: issue.issueNumber,
repositoryPath: repository?.local_path ?? null,
repositoryName: repository?.name ?? null,
/**
* What the top bar shows so a user can tell which project an open task
* belongs to: the same `owner/repo` identity the sidebar rows and the
* repository filter use, never the local clone path.
*/
repositoryLabel: repository ? repositorySlug(repository) : null,
/**
* Total number of repositories linked to the task. Used by the top-bar
* breadcrumb to render a "+N" chip next to the primary repo name when
@@ -111,6 +111,7 @@ function buildTaskTopBarProps(params: {
taskId: taskProps.taskId,
activeSessionId: params.effectiveSessionId,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
@@ -150,6 +151,7 @@ function buildTaskLayoutProps(params: {
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
baseBranch: taskProps.baseBranch,
worktreeBranch: params.merged.worktreeBranch,
isRemoteExecutor: params.remote.isRemoteExecutor,
@@ -15,7 +15,7 @@ export function buildArchivedSidebarItem(
workflowName: undefined,
workflowStepId: undefined,
workflowStepTitle: undefined,
repositoryPath: s.archivedTaskRepositoryPath,
repositoryPath: s.archivedTaskRepositoryLabel,
diffStats: undefined,
isRemoteExecutor: false,
remoteExecutorType: undefined,
@@ -22,6 +22,8 @@ export type TaskRowProps = {
activeTaskId: string | null;
selectedTaskId: string | null;
showActivityTime?: boolean;
/** False when the list is grouped by repository and the header already names it. */
showRepository?: boolean;
onSelectTask: (taskId: string) => void;
onEditTask?: (task: TaskSwitcherItem) => void;
onRenameTask?: (taskId: string, currentTitle: string) => void;
@@ -93,6 +95,7 @@ type TaskRowItemProps = Pick<
| "activeTaskId"
| "selectedTaskId"
| "showActivityTime"
| "showRepository"
| "onSelectTask"
| "selectedTaskIds"
| "onToggleSelectTask"
@@ -110,6 +113,7 @@ function TaskRowItem({
activeTaskId,
selectedTaskId,
showActivityTime,
showRepository,
onSelectTask,
selectedTaskIds,
onToggleSelectTask,
@@ -161,7 +165,8 @@ function TaskRowItem({
updatedAt={task.updatedAt}
lastActivityAt={task.lastActivityAt}
showActivityTime={showActivityTime}
repositories={task.repositories}
repositoryPath={task.repositoryPath}
showRepository={showRepository}
prInfo={task.prInfo}
queuedCount={task.queuedCount}
wipQueue={task.wipQueue}
@@ -531,3 +531,46 @@ describe("TaskSwitcher — external issue link menu", () => {
expect(closeMenu).toHaveBeenCalledOnce();
});
});
describe("TaskSwitcher repository labels", () => {
const REPOSITORY_TEST_ID = "sidebar-task-repository";
const REPOSITORY_SLUG = "kdlbs/kandev";
function renderGroupedBy(
groupKey: GroupedSidebarList["groupKey"],
groups: GroupedSidebarList["groups"],
) {
return render(
<Providers>
<TaskSwitcher
grouped={{ groups, subTasksByParentId: new Map(), groupKey }}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
/>
</Providers>,
);
}
const REPO_TASK = item("Task A", undefined, { repositoryPath: REPOSITORY_SLUG });
it("names each task's repository in a flat, ungrouped list", () => {
renderGroupedBy("none", [{ key: "__all__", label: "All", tasks: [REPO_TASK] }]);
expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(REPOSITORY_SLUG);
});
it("names each task's repository when grouped by a dimension other than repository", () => {
renderGroupedBy("workflowStep", [{ key: "step-1", label: "In progress", tasks: [REPO_TASK] }]);
expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(REPOSITORY_SLUG);
});
it("leaves the repository off the rows when the group header already names it", () => {
renderGroupedBy("repository", [
{ key: REPOSITORY_SLUG, label: REPOSITORY_SLUG, tasks: [REPO_TASK] },
]);
expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull();
});
});
@@ -14,6 +14,15 @@ export type {
} from "./task-switcher-types";
export { dispatchSidebarRowClick } from "./task-switcher-click";
/**
* Rows name their repository unless the list is already grouped by it, where
* the section header says it once for the whole group. A list whose grouping is
* unknown (a hand-built `grouped` without `groupKey`) shows the label.
*/
function shouldShowRowRepository(grouped: TaskSwitcherProps["grouped"]): boolean {
return grouped.groupKey !== "repository";
}
function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
return {
workflows: props.workflows,
@@ -21,6 +30,7 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
showActivityTime: props.showActivityTime,
showRepository: shouldShowRowRepository(props.grouped),
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
+30 -1
View File
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { TaskTopBar } from "./task-top-bar";
@@ -151,6 +151,35 @@ describe("TaskTopBar registered change requests", () => {
});
});
describe("TaskTopBar repository crumb", () => {
function repositoryCrumb(label: string) {
return within(screen.getByRole("navigation", { name: "breadcrumb" })).queryByText(label);
}
it("names the task's repository so an open task says which project it belongs to", () => {
renderTopBar(
<TaskTopBar taskId="task-1" taskTitle="Fix the sidebar" repositoryLabel="kdlbs/kandev" />,
);
const crumb = repositoryCrumb("kdlbs/kandev");
expect(crumb).toBeTruthy();
// Orientation, not navigation: there is no repository route to land on.
expect(crumb!.closest("a")).toBeNull();
// Truncation is what protects the title, so the full name lives in `title`.
expect(crumb!.getAttribute("title")).toBe("kdlbs/kandev");
});
it("renders no repository crumb for a task with no repository", () => {
renderTopBar(<TaskTopBar taskId="task-1" taskTitle="Fix the sidebar" />);
// Any static crumb, not just a slug-shaped one: a repository with no
// provider falls back to a bare name like "scratchpad", which a
// slash-matching query would happily miss.
const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" });
expect(breadcrumb.querySelector("[title]")).toBeNull();
});
});
describe("TaskTopBar archived task controls", () => {
it("passes the task ID to the unarchive button", () => {
renderTopBar(<TaskTopBar taskId="task-1" isArchived />);
+25 -4
View File
@@ -5,7 +5,7 @@ import Link from "@/components/routing/app-link";
import { IconBug, IconCircleDot } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PageTopbar } from "@/components/page-topbar";
import { PageTopbar, type ParentCrumb } from "@/components/page-topbar";
import { useOfficeProject } from "@/hooks/use-office-workspace-data";
import { TaskTopBarTitle } from "@/components/task/task-top-bar-title";
import { EditorsMenu } from "@/components/task/editors-menu";
@@ -31,6 +31,8 @@ type TaskTopBarProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
workflowSteps?: WorkflowStepperStep[];
@@ -51,6 +53,7 @@ const TaskTopBar = memo(function TaskTopBar({
taskId,
activeSessionId,
taskTitle,
repositoryLabel,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
@@ -72,6 +75,7 @@ const TaskTopBar = memo(function TaskTopBar({
const project = useOfficeProject(projectId);
const showExecutorSettings =
!isArchived && shouldShowExecutorEnvironmentControls(remoteExecutorType);
const parents = buildTaskCrumbs(project, repositoryLabel);
return (
<PageTopbar
testId="task-topbar"
@@ -79,9 +83,7 @@ const TaskTopBar = memo(function TaskTopBar({
// name and the measured width never diverge from what is shown.
title={taskTitle ?? t("task:taskDetails")}
titleSlot={<TaskTopBarTitle taskId={taskId} taskTitle={taskTitle} isArchived={isArchived} />}
parents={
project ? [{ label: project.name, href: `/office/projects/${project.id}` }] : undefined
}
parents={parents}
// This bar is desktop-only (the mobile session bar owns phones), so the
// phone-only home crumb and status trigger have no surface here.
homeAffordance="none"
@@ -125,6 +127,25 @@ const TaskTopBar = memo(function TaskTopBar({
);
});
/**
* Ancestry crumbs for the open task: its office project (when it has one) and
* then its repository, closest to the title. The repository crumb carries no
* `href` on purpose: it orients ("this task is in kdlbs/kandev") rather than
* navigating, since there is no repository route to land on.
*
* A multi-repository task names its primary repository only, which is the same
* one the rest of the page (branch pickers, Changes) treats as primary.
*/
function buildTaskCrumbs(
project: { id: string; name: string } | null | undefined,
repositoryLabel: string | null | undefined,
): ParentCrumb[] | undefined {
const crumbs: ParentCrumb[] = [];
if (project) crumbs.push({ label: project.name, href: `/office/projects/${project.id}` });
if (repositoryLabel) crumbs.push({ label: repositoryLabel });
return crumbs.length > 0 ? crumbs : undefined;
}
// IssueTrackerButtons picks the right ticket status button for a task whose
// title already carries an external issue key. Jira and Linear use the same
// TEAM-NUMBER identifier shape, so both `extract` calls would match
+29 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, it, expect } from "vitest";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import { applyGroup } from "./apply-view";
import { applyGroup, applyView } from "./apply-view";
import { i18n } from "@/lib/i18n";
import type { TaskState } from "@/lib/types/http";
@@ -87,3 +87,31 @@ describe("applyGroup — group labels are catalog-backed", () => {
expect(groups[0].label).toMatch(/[^\p{ASCII}]/u);
});
});
describe("applyGroup — grouping dimension is reported back", () => {
// Rows read `groupKey` to decide whether naming their own repository would
// just repeat the section header, so the grouped list has to carry it.
it("reports the dimension it grouped by", () => {
expect(applyGroup(REPO_TASKS, "repository").groupKey).toBe("repository");
expect(applyGroup(REPO_TASKS, "workflowStep").groupKey).toBe("workflowStep");
});
it("reports an ungrouped flat list as such", () => {
expect(applyGroup(REPO_TASKS, "none").groupKey).toBe("none");
});
// applyView post-processes the grouped list (pinning, subtask order); the
// dimension has to survive that trip, since the rows read it from there.
it("survives applyView's post-processing", () => {
const view = {
filters: [],
sort: { key: "createdAt", direction: "desc" },
group: "repository",
collapsedGroups: [],
} as unknown as Parameters<typeof applyView>[1];
expect(applyView(REPO_TASKS, view, { pinnedTaskIds: ["a"], orderedTaskIds: [] }).groupKey).toBe(
"repository",
);
});
});
+9 -1
View File
@@ -23,6 +23,13 @@ export type SidebarGroup = {
export type GroupedSidebarList = {
groups: SidebarGroup[];
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
/**
* Which dimension produced `groups`. Rows read it to decide whether their own
* repository label is redundant: grouped by repository, the section header
* already names it. Optional so a hand-built list (tests, fixtures) keeps
* meaning "unknown grouping" and falls back to showing the label.
*/
groupKey?: GroupKey;
};
export type SidebarTaskPrefs = {
@@ -336,6 +343,7 @@ export function applyGroup(
return {
groups: [{ key: "__all__", label: t(ALL_GROUP_LABEL_KEY), tasks: rootTasks }],
subTasksByParentId,
groupKey,
};
}
@@ -360,7 +368,7 @@ export function applyGroup(
sortRepoGroups(groups);
}
if (groupKey === "state") sortStateGroups(groups);
return { groups, subTasksByParentId };
return { groups, subTasksByParentId, groupKey };
}
function mergeSingleRepoUnassigned(groups: SidebarGroup[]): void {