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:
@@ -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"
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user