feat: show remote repository in task topbar (#3917)

This commit is contained in:
Carlos Florêncio
2026-09-25 10:31:38 +01:00
committed by GitHub
parent 567b965be0
commit 07959cfaea
29 changed files with 1580 additions and 75 deletions
@@ -0,0 +1,123 @@
import type { ReactNode } from "react";
import Link from "@/components/routing/app-link";
import { BreadcrumbLink } from "@kandev/ui/breadcrumb";
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
import { cn } from "@kandev/ui/lib/utils";
/** A breadcrumb between the leading navigation crumb and the page title. */
export type ParentCrumb = {
label: string;
href?: string;
externalUrl?: string;
phoneOnlyLink?: boolean;
icon?: ReactNode;
ariaLabel?: string;
title?: string;
};
export function ParentCrumbLabel({ crumb }: { crumb: ParentCrumb }) {
const body = (
<>
{crumb.icon && (
<span aria-hidden="true" className="flex shrink-0 items-center">
{crumb.icon}
</span>
)}
<span
aria-hidden={crumb.ariaLabel ? true : undefined}
title={crumb.title ?? crumb.label}
className="min-w-0 truncate"
>
{crumb.label}
</span>
{crumb.ariaLabel && <span className="sr-only">{crumb.ariaLabel}</span>}
</>
);
if (crumb.externalUrl) {
return (
<BreadcrumbLink asChild>
<a
href={crumb.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={crumb.ariaLabel}
title={crumb.title ?? crumb.label}
className="flex max-w-40 min-w-0 items-center gap-1.5 truncate cursor-pointer text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
{body}
</a>
</BreadcrumbLink>
);
}
if (crumb.href) {
return (
<>
<BreadcrumbLink asChild>
<Link
href={crumb.href}
className={cn(
"flex max-w-40 min-w-0 items-center gap-1.5 truncate cursor-pointer text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline",
crumb.phoneOnlyLink && "md:hidden",
)}
aria-label={crumb.ariaLabel}
title={crumb.title ?? crumb.label}
>
{body}
</Link>
</BreadcrumbLink>
{crumb.phoneOnlyLink && (
<span
title={crumb.title ?? crumb.label}
className="hidden max-w-40 min-w-0 cursor-default truncate text-muted-foreground/60 md:inline"
>
{body}
</span>
)}
</>
);
}
return (
<span
title={crumb.title ?? crumb.label}
className="flex max-w-40 min-w-0 cursor-default items-center gap-1.5 truncate text-muted-foreground/60"
>
{body}
</span>
);
}
export function ParentCrumbMenuItem({ crumb }: { crumb: ParentCrumb }) {
if (crumb.externalUrl) {
return (
<DropdownMenuItem asChild>
<a
href={crumb.externalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={crumb.ariaLabel}
title={crumb.title ?? crumb.label}
>
{crumb.icon && (
<span aria-hidden="true" className="flex shrink-0 items-center">
{crumb.icon}
</span>
)}
<span>{crumb.label}</span>
</a>
</DropdownMenuItem>
);
}
if (crumb.href) {
return (
<DropdownMenuItem asChild>
<Link href={crumb.href}>{crumb.label}</Link>
</DropdownMenuItem>
);
}
return <DropdownMenuItem disabled>{crumb.label}</DropdownMenuItem>;
}
+21 -1
View File
@@ -1,6 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PageTopbar, TOPBAR_HEIGHT_CLASSNAME } from "./page-topbar";
import { PageTopbar, TOPBAR_HEIGHT_CLASSNAME, type ParentCrumb } from "./page-topbar";
vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({
AppStatusDrawerTrigger: () => null,
@@ -109,6 +109,26 @@ describe("PageTopbar parent crumbs", () => {
"max-md:hidden",
);
});
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.2
it("renders an external repository parent as a named new-tab link with its provider icon", () => {
const ariaLabel = "GitHub repository owner/agent-orchestrator";
const repositoryCrumb = {
label: "agent-orchestrator",
externalUrl: "https://github.com/owner/agent-orchestrator",
ariaLabel,
title: "owner/agent-orchestrator",
icon: <svg data-testid="repository-provider-icon" />,
} as unknown as ParentCrumb;
render(<PageTopbar title="Explain agent connections" parents={[repositoryCrumb]} />);
const link = screen.getByRole("link", { name: ariaLabel });
expect(link.getAttribute("href")).toBe("https://github.com/owner/agent-orchestrator");
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
expect(link.querySelector('[data-testid="repository-provider-icon"]')).not.toBeNull();
});
});
describe("PageTopbar titleSlot", () => {
+24 -60
View File
@@ -12,13 +12,14 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { cn } from "@kandev/ui/lib/utils";
import {
ParentCrumbLabel,
ParentCrumbMenuItem,
type ParentCrumb,
} from "@/components/page-topbar-parent-crumb";
export type { ParentCrumb } from "@/components/page-topbar-parent-crumb";
import { AppStatusDrawerTrigger } from "@/components/app-status-bar/app-status-surface-provider";
import { useTopbarPressure } from "@/hooks/use-topbar-pressure";
import { linkToTaskOverview } from "@/lib/links";
@@ -26,13 +27,6 @@ import { linkToTaskOverview } from "@/lib/links";
/** The one bar height. Bespoke bars adopt this token instead of redeclaring it. */
export const TOPBAR_HEIGHT_CLASSNAME = "h-10 min-h-11 md:min-h-10";
/**
* A middle breadcrumb between the leading crumb and the page title. No `href`
* means orientation rather than navigation; `phoneOnlyLink` keeps the crumb
* clickable below `md` (where the sidebar is hidden) and static above it.
*/
export type ParentCrumb = { label: string; href?: string; phoneOnlyLink?: boolean };
type PageTopbarProps = {
/** Page title shown as the rightmost (current) breadcrumb */
title: string;
@@ -251,40 +245,6 @@ function TitleCrumb({
);
}
function ParentCrumbLabel({ crumb }: { crumb: ParentCrumb }) {
if (crumb.href) {
return (
<>
<BreadcrumbLink asChild>
<Link
href={crumb.href}
className={cn(
"max-w-40 truncate cursor-pointer text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline",
crumb.phoneOnlyLink && "md:hidden",
)}
>
{crumb.label}
</Link>
</BreadcrumbLink>
{crumb.phoneOnlyLink && (
<span className="hidden max-w-40 cursor-default truncate text-muted-foreground/60 md:inline">
{crumb.label}
</span>
)}
</>
);
}
// 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 title={crumb.label} className="max-w-40 cursor-default truncate text-muted-foreground/60">
{crumb.label}
</span>
);
}
/**
* Middle crumbs: the full chain from `md` up; below `md` everything except the
* last parent collapses into a `…` dropdown so deep paths stay one row —
@@ -332,17 +292,12 @@ function ParentCrumbs({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{collapsed.map((p) =>
p.href ? (
<DropdownMenuItem key={p.href} asChild>
<Link href={p.href}>{p.label}</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem key={p.label} disabled>
{p.label}
</DropdownMenuItem>
),
)}
{collapsed.map((p, index) => (
<ParentCrumbMenuItem
key={`${p.externalUrl ?? p.href ?? p.label}-${index}`}
crumb={p}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
@@ -370,6 +325,15 @@ function GhostLabel({ label, className }: { label: string; className?: string })
return <span data-label={label} className={cn("before:content-[attr(data-label)]", className)} />;
}
function GhostParentLabel({ crumb }: { crumb: ParentCrumb }) {
return (
<span className="flex max-w-40 min-w-0 items-center gap-1.5">
{crumb.icon && <span aria-hidden className="size-3.5 shrink-0" />}
<GhostLabel label={crumb.label} className="min-w-0 truncate" />
</span>
);
}
type TopbarGhostProps = {
ghostRef: RefObject<HTMLDivElement | null>;
backHref: string;
@@ -426,7 +390,7 @@ function TopbarGhost({
{lead}
{chain.map((p, index) => (
<span key={`${p.href ?? p.label}-${index}`} className="flex items-center gap-1.5">
<GhostLabel label={p.label} className="max-w-40 truncate" />
<GhostParentLabel crumb={p} />
<GhostSeparator />
</span>
))}
@@ -441,7 +405,7 @@ function TopbarGhost({
)}
{lastParent && (
<>
<GhostLabel label={lastParent.label} className="max-w-40 truncate" />
<GhostParentLabel crumb={lastParent} />
<GhostSeparator />
</>
)}
@@ -215,7 +215,7 @@ describe("SettingsLayoutClient breadcrumbs", () => {
expect(link.className).toContain("md:hidden");
const desktopText = screen
.getAllByText("Settings")
.find((el) => el.tagName === "SPAN" && el.className.includes("md:inline"));
.find((el) => el.parentElement?.className.includes("md:inline"));
expect(desktopText).toBeTruthy();
});
@@ -5,7 +5,7 @@ import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon";
import type { RemoteRepositoryProvider } from "@/hooks/domains/integrations/use-remote-repositories";
import { pluginRegistry } from "@/lib/plugins/registry";
import { usePluginRegistry } from "@/lib/plugins/registry";
import { resolvePluginIcon } from "@/lib/plugins/icons";
import { cn } from "@/lib/utils";
@@ -16,22 +16,31 @@ const PROVIDER_LABELS: Record<string, string> = {
};
export function RemoteRepositoryProviderIcon({ provider }: { provider: RemoteRepositoryProvider }) {
const registry = usePluginRegistry();
if (provider === "github") return <IconBrandGithub className="size-3.5 shrink-0" />;
if (provider === "gitlab") return <IconBrandGitlab className="size-3.5 shrink-0" />;
if (provider === "azure_devops") return <AzureDevOpsIcon className="size-3.5 shrink-0" />;
const registration = pluginRegistry.getRepositoryProvider(provider);
const registration = registry.getRepositoryProvider(provider);
const Icon = registration ? resolvePluginIcon(registration.icon) : IconGitBranch;
return <Icon className="size-3.5 shrink-0" />;
}
function providerLabel(provider: RemoteRepositoryProvider): string {
export function remoteRepositoryProviderLabel(
provider: RemoteRepositoryProvider,
registeredLabel?: string,
): string {
return (
PROVIDER_LABELS[provider] ??
pluginRegistry.getRepositoryProvider(provider)?.label ??
registeredLabel ??
provider.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
);
}
export function useRemoteRepositoryProviderLabel(provider: RemoteRepositoryProvider): string {
const registry = usePluginRegistry();
return remoteRepositoryProviderLabel(provider, registry.getRepositoryProvider(provider)?.label);
}
function ProviderTab({
provider,
compact,
@@ -39,7 +48,7 @@ function ProviderTab({
provider: RemoteRepositoryProvider;
compact: boolean;
}) {
const label = providerLabel(provider);
const label = useRemoteRepositoryProviderLabel(provider);
const trigger = (
<TabsTrigger
value={provider}
@@ -41,6 +41,7 @@ import { useTranslation } from "react-i18next";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { LaunchQueueStatus } from "../launch-queue-status";
import { WipQueueStatus } from "../wip-queue-status";
import type { TaskTopbarRepository } from "../task-page-content-helpers";
export { resolveMobilePluginPanel } from "./mobile-plugin-panel-lifecycle";
@@ -93,6 +94,7 @@ type SessionMobileLayoutProps = {
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
topbarRepository?: TaskTopbarRepository | null;
isRemoteExecutor?: boolean;
remoteExecutorType?: string | null;
remoteExecutorName?: string | null;
@@ -417,6 +419,7 @@ type MobileTopBarStickyProps = {
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
topbarRepository?: TaskTopbarRepository | null;
effectiveSessionId: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
@@ -446,6 +449,7 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) {
workspaceId={props.workspaceId}
taskTitle={props.taskTitle}
repositoryLabel={props.repositoryLabel}
topbarRepository={props.topbarRepository}
sessionId={props.effectiveSessionId}
baseBranch={props.baseBranch}
worktreeBranch={props.worktreeBranch}
@@ -1,11 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, 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 { pluginRegistry } from "@/lib/plugins/registry";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
afterEach(cleanup);
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(TEST_PROVIDER_PLUGIN_ID);
});
// Git metrics are not what this file is about; stub the two session-data hooks
// so the header renders standalone.
@@ -38,6 +42,27 @@ vi.mock("@/components/task/task-unarchive-button", () => ({
}));
const REPOSITORY_TEST_ID = "mobile-task-repository";
const TEST_PROVIDER_PLUGIN_ID = "mobile-task-topbar-provider-test";
const TEST_PROVIDER_ID = "test_source_control";
const REMOTE_REPOSITORY_NAME = "agent-orchestrator";
const REMOTE_REPOSITORY_FULL_NAME = `owner/${REMOTE_REPOSITORY_NAME}`;
const REMOTE_REPOSITORY_URL = `https://github.com/${REMOTE_REPOSITORY_FULL_NAME}`;
const REPOSITORY_LINK_TEST_ID = "mobile-task-repository-link";
function TestProviderIcon({ className }: { className?: string }) {
return <svg className={className} data-testid="registered-mobile-provider-icon" />;
}
function registerTestRepositoryProvider() {
pluginRegistry.forPlugin(TEST_PROVIDER_PLUGIN_ID).registerRepositoryProvider({
id: TEST_PROVIDER_ID,
label: "Test Forge",
icon: TestProviderIcon,
listRepositories: async () => [],
listBranches: async () => [],
inspectURL: async () => null,
});
}
function renderTopBar(props: Record<string, unknown> = {}) {
return render(
@@ -60,6 +85,78 @@ function renderTopBar(props: Record<string, unknown> = {}) {
}
describe("SessionMobileTopBar repository", () => {
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.2,
// AC-UI-REMOTE-REPO-TOPBAR-001.5
it("renders one remote repository before the task picker as its own external link", () => {
renderTopBar({
repositoryLabel: REMOTE_REPOSITORY_FULL_NAME,
topbarRepository: {
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: "github",
browserUrl: REMOTE_REPOSITORY_URL,
},
});
const link = screen.getByTestId(REPOSITORY_LINK_TEST_ID);
const picker = screen.getByTestId("mobile-task-picker-trigger");
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe(REMOTE_REPOSITORY_URL);
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
expect(picker.contains(link)).toBe(false);
expect(link.compareDocumentPosition(picker) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull();
});
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.4
it("keeps the remote repository identity visible without rendering an unsafe link", () => {
renderTopBar({
topbarRepository: {
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: "github",
browserUrl: null,
},
});
expect(screen.getByTestId("mobile-task-repository-label").textContent).toContain(
REMOTE_REPOSITORY_NAME,
);
expect(screen.queryByTestId(REPOSITORY_LINK_TEST_ID)).toBeNull();
});
it("refreshes the provider label and icon after plugin registration changes", () => {
renderTopBar({
topbarRepository: {
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: TEST_PROVIDER_ID,
browserUrl: `https://code.example.test/${REMOTE_REPOSITORY_FULL_NAME}`,
},
});
const repositoryLink = screen.getByTestId(REPOSITORY_LINK_TEST_ID);
expect(repositoryLink.getAttribute("aria-label")).toBe(
`Test Source Control repository ${REMOTE_REPOSITORY_FULL_NAME}`,
);
expect(screen.queryByTestId("registered-mobile-provider-icon")).toBeNull();
act(() => registerTestRepositoryProvider());
expect(screen.getByTestId(REPOSITORY_LINK_TEST_ID).getAttribute("aria-label")).toBe(
`Test Forge repository ${REMOTE_REPOSITORY_FULL_NAME}`,
);
expect(screen.getByTestId("registered-mobile-provider-icon")).toBeTruthy();
act(() => pluginRegistry.unregisterPlugin(TEST_PROVIDER_PLUGIN_ID));
expect(screen.getByTestId(REPOSITORY_LINK_TEST_ID).getAttribute("aria-label")).toBe(
`Test Source Control repository ${REMOTE_REPOSITORY_FULL_NAME}`,
);
expect(screen.queryByTestId("registered-mobile-provider-icon")).toBeNull();
});
it("names the task's repository, so the phone header says which project this is", () => {
renderTopBar({ repositoryLabel: "kdlbs/kandev" });
@@ -2,7 +2,13 @@
import { memo } from "react";
import Link from "@/components/routing/app-link";
import { IconArrowLeft, IconChevronDown, IconGitBranch, IconCheck } from "@tabler/icons-react";
import {
IconArrowLeft,
IconChevronDown,
IconChevronRight,
IconGitBranch,
IconCheck,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
@@ -20,6 +26,11 @@ import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { linkToTaskOverview } from "@/lib/links";
import { AppNavSheet } from "@/components/navigation/app-nav-sheet";
import { useTranslation } from "react-i18next";
import {
RemoteRepositoryProviderIcon,
useRemoteRepositoryProviderLabel,
} from "@/components/task-create-dialog-remote-repo-provider-tabs";
import type { TaskTopbarRepository } from "../task-page-content-helpers";
type SessionMobileTopBarProps = {
taskId?: string | null;
@@ -27,6 +38,7 @@ type SessionMobileTopBarProps = {
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
topbarRepository?: TaskTopbarRepository | null;
sessionId?: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
@@ -111,6 +123,64 @@ function MobileTaskTitle({
);
}
function MobileRepositoryLabel({ repository }: { repository: TaskTopbarRepository }) {
const { t } = useTranslation();
const providerLabel = useRemoteRepositoryProviderLabel(repository.provider);
const accessibleName = t("task:remoteRepositoryIdentity", {
provider: providerLabel,
repository: repository.fullName,
});
const icon = (
<span
data-testid="mobile-task-repository-provider-icon"
aria-hidden="true"
className="flex shrink-0 items-center"
>
<RemoteRepositoryProviderIcon provider={repository.provider} />
</span>
);
const name = (
<span data-testid="mobile-task-repository-name" aria-hidden="true" className="min-w-0 truncate">
{repository.displayName}
</span>
);
const className =
"flex h-11 min-h-11 min-w-11 max-w-[38%] shrink items-center gap-1 rounded-md px-1.5 text-xs text-muted-foreground";
const content = (
<span className="flex min-w-0 items-center gap-1">
{icon}
{name}
</span>
);
if (repository.browserUrl) {
return (
<a
href={repository.browserUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={accessibleName}
title={repository.fullName}
data-testid="mobile-task-repository-link"
className={`${className} cursor-pointer hover:bg-muted hover:text-foreground`}
>
{content}
</a>
);
}
return (
<span
title={repository.fullName}
data-testid="mobile-task-repository-label"
className={`${className} cursor-default`}
>
{content}
<span className="sr-only">{accessibleName}</span>
</span>
);
}
export function MobileRemoteExecutorIndicator({
taskId,
sessionId,
@@ -283,9 +353,18 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
<IconArrowLeft className="h-4 w-4" />
</Link>
</Button>
{props.topbarRepository && (
<>
<MobileRepositoryLabel repository={props.topbarRepository} />
<IconChevronRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
</>
)}
<MobileTaskTitle
taskTitle={props.taskTitle}
repositoryLabel={props.repositoryLabel}
repositoryLabel={props.topbarRepository ? null : props.repositoryLabel}
displayBranch={displayBranch}
totalAdditions={totalAdditions}
totalDeletions={totalDeletions}
+4
View File
@@ -15,6 +15,7 @@ import { useTaskCanvasLifecycleActivation } from "./dockview-canvas-activation";
import { statusSummaryTaskError } from "@/lib/task-status-summary";
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
import type { TaskCanvasesLoadStatus } from "@/hooks/domains/task/use-task-canvases";
import type { TaskTopbarRepository } from "./task-page-content-helpers";
// Re-export for backwards compatibility
export type { SelectedDiff } from "@/hooks/use-session-layout-state";
@@ -37,6 +38,7 @@ type TaskLayoutProps = {
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
topbarRepository?: TaskTopbarRepository | null;
baseBranch?: string;
worktreeBranch?: string | null;
isRemoteExecutor?: boolean;
@@ -81,6 +83,7 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({
defaultLayouts = {},
taskTitle,
repositoryLabel,
topbarRepository,
baseBranch,
worktreeBranch,
isRemoteExecutor,
@@ -123,6 +126,7 @@ const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({
worktreeBranch={worktreeBranch}
taskTitle={taskTitle}
repositoryLabel={repositoryLabel}
topbarRepository={topbarRepository}
isRemoteExecutor={isRemoteExecutor}
remoteExecutorType={remoteExecutorType}
remoteExecutorName={remoteExecutorName}
@@ -94,7 +94,115 @@ describe("buildDebugEntries", () => {
});
});
describe("resolveTaskProps", () => {
describe("resolveTaskProps remote repository topbar projection", () => {
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.3
it("projects the short identity and safe browser URL for one resolved remote repository", () => {
const repository = {
id: "repo-1",
name: "agent-orchestrator",
source_type: "provider",
provider: "github",
provider_owner: "Untrivial-ai",
provider_name: "agent-orchestrator",
remote_url: "https://github.com/Untrivial-ai/agent-orchestrator.git/",
} as unknown as Repository;
const task = {
id: "task-1",
title: "Explain agent connections",
repositories: [{ repository_id: "repo-1" }],
} as unknown as Task;
const props = resolveTaskProps(task, repository);
expect(props.repositoryLabel).toBe("Untrivial-ai/agent-orchestrator");
expect(props.topbarRepository).toEqual({
displayName: "agent-orchestrator",
fullName: "Untrivial-ai/agent-orchestrator",
provider: "github",
browserUrl: "https://github.com/Untrivial-ai/agent-orchestrator",
});
});
});
describe("resolveTaskProps repository eligibility", () => {
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.2, AC-UI-REMOTE-REPO-TOPBAR-001.4
it.each([
["a local repository", "local", [{ repository_id: "repo-1" }], "https://github.com/owner/repo"],
[
"a task with multiple repositories",
"provider",
[{ repository_id: "repo-1" }, { repository_id: "repo-2" }],
"https://github.com/owner/repo",
],
[
"an unresolved primary repository",
"provider",
[{ repository_id: "repo-2" }],
"https://github.com/owner/repo",
],
])(
"does not create a topbar-only link for %s",
(_reason, sourceType, taskRepositories, remoteURL) => {
const repository = {
id: "repo-1",
name: "repo",
source_type: sourceType,
provider: "github",
provider_owner: "owner",
provider_name: "repo",
remote_url: remoteURL,
} as unknown as Repository;
const props = resolveTaskProps(
{ id: "task-1", title: "Any", repositories: taskRepositories } as unknown as Task,
repository,
);
expect(props.topbarRepository).toBeNull();
},
);
});
describe("resolveTaskProps safe browser URL", () => {
it.each([
undefined,
"",
"git@github.com:owner/repo.git",
"http://github.com/owner/repo",
"https://user:secret@github.com/owner/repo",
"https://github.com/owner/repo?tab=code",
"https://github.com/owner/repo#readme",
"not a URL",
])("keeps an unsafe or missing remote URL out of browser navigation: %s", (remoteURL) => {
const repository = {
id: "repo-1",
name: "repo",
source_type: "provider",
provider: "github",
provider_owner: "owner",
provider_name: "repo",
remote_url: remoteURL,
} as unknown as Repository;
const props = resolveTaskProps(
{
id: "task-1",
title: "Any",
repositories: [{ repository_id: "repo-1" }],
} as unknown as Task,
repository,
);
expect(props.topbarRepository).toMatchObject({
displayName: "repo",
fullName: "owner/repo",
provider: "github",
browserUrl: null,
});
});
});
describe("resolveTaskProps additional metadata", () => {
it("exposes linked GitHub issue metadata for the top bar", () => {
const props = resolveTaskProps(
{
@@ -9,12 +9,20 @@ import {
import type { KanbanState } from "@/lib/state/slices";
import { issueFieldsFromMetadata } from "@/lib/metadata-utils";
import { repositorySlug } from "@/lib/repository-slug";
import { remoteRepositoryBrowserUrl } from "@/lib/utils/remote-repository-browser-url";
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
import { useAppStore } from "@/components/state-provider";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
const EMPTY_REPOSITORIES: Repository[] = [];
export type TaskTopbarRepository = {
displayName: string;
fullName: string;
provider: string;
browserUrl: string | null;
};
export function selectWorkspaceRepositories(
itemsByWorkspaceId: Record<string, Repository[]>,
workspaceId: string | null | undefined,
@@ -336,6 +344,7 @@ export function resolveTaskProps(
* repository filter use, never the local clone path.
*/
repositoryLabel: repository ? repositorySlug(repository) : null,
topbarRepository: resolveTaskTopbarRepository(task, repository),
...pullRequestProps,
/**
* Total number of repositories linked to the task. Used by the top-bar
@@ -345,3 +354,26 @@ export function resolveTaskProps(
repositoryCount: task?.repositories?.length ?? 0,
};
}
function resolveTaskTopbarRepository(
task: Task | null,
repository: Repository | null,
): TaskTopbarRepository | null {
const linkedRepository = task?.repositories?.[0];
if (
task?.repositories?.length !== 1 ||
!repository ||
linkedRepository?.repository_id !== repository.id ||
repository.source_type !== "provider"
) {
return null;
}
const displayName = repository.provider_name?.trim() || repository.name?.trim();
return {
displayName: displayName || repositorySlug(repository),
fullName: repositorySlug(repository),
provider: repository.provider,
browserUrl: remoteRepositoryBrowserUrl(repository.remote_url, repository.provider),
};
}
@@ -133,6 +133,7 @@ function buildTaskTopBarProps(params: {
activeSessionId: params.effectiveSessionId,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
topbarRepository: taskProps.topbarRepository,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
@@ -187,6 +188,7 @@ function buildTaskLayoutProps(params: {
taskCanvasesStatus: params.taskCanvasesStatus,
taskTitle: taskProps.taskTitle,
repositoryLabel: taskProps.repositoryLabel,
topbarRepository: taskProps.topbarRepository,
baseBranch: taskProps.baseBranch,
worktreeBranch: params.merged.worktreeBranch,
isRemoteExecutor: params.remote.isRemoteExecutor,
+115 -1
View File
@@ -4,10 +4,35 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { defaultState } from "@/lib/state/default-state";
import { pluginRegistry } from "@/lib/plugins/registry";
import { TaskTopBar } from "./task-top-bar";
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
afterEach(() => cleanup());
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(TEST_PROVIDER_PLUGIN_ID);
});
const TEST_PROVIDER_PLUGIN_ID = "task-topbar-repository-provider-test";
const TEST_PROVIDER_ID = "test_source_control";
const REMOTE_REPOSITORY_NAME = "agent-orchestrator";
const REMOTE_REPOSITORY_FULL_NAME = `owner/${REMOTE_REPOSITORY_NAME}`;
const REMOTE_REPOSITORY_URL = `https://github.com/${REMOTE_REPOSITORY_FULL_NAME}`;
function TestProviderIcon({ className }: { className?: string }) {
return <svg className={className} data-testid="registered-provider-icon" />;
}
function registerTestRepositoryProvider() {
pluginRegistry.forPlugin(TEST_PROVIDER_PLUGIN_ID).registerRepositoryProvider({
id: TEST_PROVIDER_ID,
label: "Test Forge",
icon: TestProviderIcon,
listRepositories: async () => [],
listBranches: async () => [],
inspectURL: async () => null,
});
}
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -188,6 +213,95 @@ describe("TaskTopBar repository crumb", () => {
const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" });
expect(breadcrumb.querySelector("[title]")).toBeNull();
});
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.2
it("links the short remote repository crumb while keeping the task title separate", () => {
renderTopBar(
<TaskTopBar
taskId="task-1"
taskTitle={TASK_TITLE}
topbarRepository={{
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: "github",
browserUrl: REMOTE_REPOSITORY_URL,
}}
/>,
);
const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" });
const repository = within(breadcrumb).getByRole("link", {
name: `GitHub repository ${REMOTE_REPOSITORY_FULL_NAME}`,
});
expect(repository.getAttribute("href")).toBe(REMOTE_REPOSITORY_URL);
expect(repository.getAttribute("target")).toBe("_blank");
expect(repository.getAttribute("rel")).toBe("noopener noreferrer");
expect(
repository.querySelector('[data-testid="task-topbar-repository-provider-icon"]'),
).not.toBeNull();
expect(screen.getByTestId("task-topbar-title").textContent).toBe(TASK_TITLE);
});
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.4
it("shows a static remote repository crumb when its browser URL is unavailable", () => {
renderTopBar(
<TaskTopBar
taskId="task-1"
taskTitle={TASK_TITLE}
topbarRepository={{
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: "github",
browserUrl: null,
}}
/>,
);
const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" });
const repositoryLabel = within(breadcrumb).getByText(REMOTE_REPOSITORY_NAME);
expect(repositoryLabel.closest("a")).toBeNull();
expect(
within(breadcrumb).getByText(`GitHub repository ${REMOTE_REPOSITORY_FULL_NAME}`),
).not.toBeNull();
expect(
repositoryLabel
.closest("li")
?.querySelector('[data-testid="task-topbar-repository-provider-icon"]'),
).not.toBeNull();
});
it("refreshes the provider label and icon after plugin registration changes", () => {
renderTopBar(
<TaskTopBar
taskId="task-1"
taskTitle={TASK_TITLE}
topbarRepository={{
displayName: REMOTE_REPOSITORY_NAME,
fullName: REMOTE_REPOSITORY_FULL_NAME,
provider: TEST_PROVIDER_ID,
browserUrl: `https://code.example.test/${REMOTE_REPOSITORY_FULL_NAME}`,
}}
/>,
);
const findRepositoryLink = (providerLabel: string) =>
screen.getByRole("link", {
name: `${providerLabel} repository ${REMOTE_REPOSITORY_FULL_NAME}`,
});
expect(findRepositoryLink("Test Source Control")).toBeTruthy();
expect(screen.queryByTestId("registered-provider-icon")).toBeNull();
act(() => registerTestRepositoryProvider());
expect(findRepositoryLink("Test Forge")).toBeTruthy();
expect(screen.getByTestId("registered-provider-icon")).toBeTruthy();
act(() => pluginRegistry.unregisterPlugin(TEST_PROVIDER_PLUGIN_ID));
expect(findRepositoryLink("Test Source Control")).toBeTruthy();
expect(screen.queryByTestId("registered-provider-icon")).toBeNull();
});
});
describe("TaskTopBar archived task controls", () => {
+39 -2
View File
@@ -27,9 +27,14 @@ import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-a
import { TaskTopBarActionsMenu } from "@/components/task/task-top-bar-actions-menu";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import { RegisteredChangeRequestStatus } from "@/components/integrations/registered-change-request-status";
import {
RemoteRepositoryProviderIcon,
useRemoteRepositoryProviderLabel,
} from "@/components/task-create-dialog-remote-repo-provider-tabs";
import { isDebugUI } from "@/lib/config";
import { useTranslation } from "react-i18next";
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
import type { TaskTopbarRepository } from "./task-page-content-helpers";
type TaskTopBarProps = {
taskId?: string | null;
@@ -37,6 +42,7 @@ type TaskTopBarProps = {
taskTitle?: string;
/** `owner/repo` (or the repository name) of the task's primary repository. */
repositoryLabel?: string | null;
topbarRepository?: TaskTopbarRepository | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
workflowSteps?: WorkflowStepperStep[];
@@ -66,6 +72,7 @@ const TaskTopBar = memo(function TaskTopBar({
activeSessionId,
taskTitle,
repositoryLabel,
topbarRepository,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
@@ -91,9 +98,23 @@ const TaskTopBar = memo(function TaskTopBar({
// Projects only exist for office-owned tasks, so kanban-mode tasks render no
// ancestry trail at all.
const project = useOfficeProject(projectId);
const repositoryProviderLabel = useRemoteRepositoryProviderLabel(
topbarRepository?.provider ?? "",
);
const showExecutorSettings =
!isArchived && shouldShowExecutorEnvironmentControls(remoteExecutorType);
const parents = buildTaskCrumbs(project, repositoryLabel);
const repositoryAccessibleName = topbarRepository
? t("task:remoteRepositoryIdentity", {
provider: repositoryProviderLabel,
repository: topbarRepository.fullName,
})
: undefined;
const parents = buildTaskCrumbs(
project,
repositoryLabel,
topbarRepository,
repositoryAccessibleName,
);
return (
<PageTopbar
testId="task-topbar"
@@ -163,10 +184,26 @@ const TaskTopBar = memo(function TaskTopBar({
function buildTaskCrumbs(
project: { id: string; name: string } | null | undefined,
repositoryLabel: string | null | undefined,
topbarRepository?: TaskTopbarRepository | null,
repositoryAccessibleName?: string,
): ParentCrumb[] | undefined {
const crumbs: ParentCrumb[] = [];
if (project) crumbs.push({ label: project.name, href: `/office/projects/${project.id}` });
if (repositoryLabel) crumbs.push({ label: repositoryLabel });
if (topbarRepository) {
crumbs.push({
label: topbarRepository.displayName,
externalUrl: topbarRepository.browserUrl ?? undefined,
ariaLabel: repositoryAccessibleName,
title: topbarRepository.fullName,
icon: (
<span data-testid="task-topbar-repository-provider-icon">
<RemoteRepositoryProviderIcon provider={topbarRepository.provider} />
</span>
),
});
} else if (repositoryLabel) {
crumbs.push({ label: repositoryLabel });
}
return crumbs.length > 0 ? crumbs : undefined;
}
@@ -0,0 +1,292 @@
import { test, expect } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { seedTaskWithLinkedGitLabMRs, GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
import { SessionPage } from "../../pages/session-page";
const repositoryOwner = "e2e-phone-owner";
const repositoryName =
"agent-orchestrator-with-a-deliberately-long-name-for-phone-truncation-check";
const repositoryBrowserUrl = `https://github.com/${repositoryOwner}/${repositoryName}`;
const taskTitle = "Explain agent connections on a phone";
type WebSocketFrame = {
action?: unknown;
id?: unknown;
payload?: unknown;
type?: unknown;
};
async function addRemoteExecutorStatus(page: Page) {
const statusRequests = new Set<string>();
await page.routeWebSocket(/\/ws$/, (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => {
if (typeof message === "string") {
for (const part of message.split("\n")) {
try {
const frame = JSON.parse(part) as WebSocketFrame;
if (
frame.type === "request" &&
frame.action === "task.session.status" &&
typeof frame.id === "string"
) {
statusRequests.add(frame.id);
}
} catch {
// Ignore non-JSON WebSocket frames.
}
}
}
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
socket.send(message);
return;
}
const rewritten = message
.split("\n")
.map((part) => {
try {
const frame = JSON.parse(part) as WebSocketFrame;
if (
frame.type !== "response" ||
typeof frame.id !== "string" ||
!statusRequests.delete(frame.id) ||
typeof frame.payload !== "object" ||
frame.payload === null
) {
return part;
}
return JSON.stringify({
...frame,
payload: {
...frame.payload,
is_remote_executor: true,
executor_type: "sprites",
executor_name: "Remote test runner",
remote_name: "Remote test runner",
remote_state: "running",
remote_checked_at: new Date().toISOString(),
},
});
} catch {
return part;
}
})
.join("\n");
socket.send(rewritten);
});
});
}
async function expectCenterIsReachable(locator: ReturnType<Page["locator"]>, label: string) {
const result = await locator.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const hit = document.elementFromPoint(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2,
);
return {
receivesPointer: hit === element || (hit !== null && element.contains(hit)),
bounds: { left: bounds.left, right: bounds.right, width: bounds.width },
viewportWidth: document.documentElement.clientWidth,
hit: hit
? {
tagName: hit.tagName,
testId: hit.getAttribute("data-testid"),
className: typeof hit.className === "string" ? hit.className : "svg",
}
: null,
};
});
expect(result.receivesPointer, `${label} center is covered: ${JSON.stringify(result)}`).toBe(
true,
);
}
test.describe("Mobile task topbar remote repository", () => {
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.2,
// AC-UI-REMOTE-REPO-TOPBAR-001.5
test("keeps the repository link and task picker usable on a phone", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, taskTitle, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [
{
remote_url: `${repositoryBrowserUrl}.git`,
provider: "github",
provider_owner: repositoryOwner,
provider_name: repositoryName,
},
],
});
await testPage.goto(`/t/${task.id}`);
const repositoryLink = testPage.getByTestId("mobile-task-repository-link");
const taskPicker = testPage.getByTestId("mobile-task-picker-trigger");
await expect(repositoryLink).toHaveAttribute("href", repositoryBrowserUrl);
await expect(repositoryLink).toHaveAttribute("target", "_blank");
await expect(repositoryLink).toHaveAccessibleName(
`GitHub repository ${repositoryOwner}/${repositoryName}`,
);
await expect(taskPicker).toContainText(taskTitle);
const repositoryBox = await repositoryLink.boundingBox();
const taskPickerBox = await taskPicker.boundingBox();
expect(repositoryBox).not.toBeNull();
expect(taskPickerBox).not.toBeNull();
expect(repositoryBox!.width).toBeGreaterThanOrEqual(44);
expect(repositoryBox!.height).toBeGreaterThanOrEqual(44);
expect(taskPickerBox!.width).toBeGreaterThanOrEqual(44);
await expect
.poll(() =>
testPage.getByTestId("mobile-task-repository-name").evaluate((node) => {
const label = node as HTMLElement;
return label.scrollWidth > label.clientWidth;
}),
)
.toBe(true);
await assertNoDocumentHorizontalOverflow(testPage, "remote repository task topbar");
await prCapture.screenshot("task-topbar-remote-repository-mobile", {
caption: "Phone task topbar with a truncated repository link and the task picker.",
});
await testPage
.context()
.route("https://github.com/**", (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "repository" }),
);
const popupPromise = testPage.waitForEvent("popup");
await repositoryLink.tap();
const popup = await popupPromise;
await expect.poll(() => popup.url()).toBe(repositoryBrowserUrl);
await expect(taskPicker).toBeVisible();
await taskPicker.tap();
await expect(testPage.getByRole("dialog", { name: "Tasks" })).toBeVisible();
});
test("keeps topbar actions reachable at 320px with a linked MR, forwarding, and remote executor", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(180_000);
await testPage.setViewportSize({ width: 320, height: 800 });
await addRemoteExecutorStatus(testPage);
const taskId = await seedTaskWithLinkedGitLabMRs(
apiClient,
{
workspaceId: seedData.workspaceId,
repositoryId: seedData.repositoryId,
agentProfileId: seedData.agentProfileId,
workflowId: seedData.workflowId,
startStepId: seedData.startStepId,
},
"Narrow action-rich phone header",
[191],
"Narrow header MR",
);
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.evaluate(
({ workspaceId, repositoryId, remoteUrl, providerHost }) => {
const store = (
window as unknown as {
__KANDEV_E2E_STORE__?: {
getState(): {
repositories: {
itemsByWorkspaceId: Record<string, Array<Record<string, unknown>>>;
};
};
setState(value: unknown): void;
};
}
).__KANDEV_E2E_STORE__;
if (!store) throw new Error("The E2E app store is unavailable");
const state = store.getState();
const repositories = state.repositories.itemsByWorkspaceId[workspaceId] ?? [];
let found = false;
const updatedRepositories = repositories.map((repository) => {
if (repository.id !== repositoryId) return repository;
found = true;
return {
...repository,
source_type: "provider",
remote_url: remoteUrl,
provider: "gitlab",
provider_host: providerHost,
provider_owner: "platform",
provider_name: "kandev",
};
});
if (!found) throw new Error("The task repository is absent from the E2E app store");
store.setState({
repositories: {
...state.repositories,
itemsByWorkspaceId: {
...state.repositories.itemsByWorkspaceId,
[workspaceId]: updatedRepositories,
},
},
});
},
{
workspaceId: seedData.workspaceId,
repositoryId: seedData.repositoryId,
remoteUrl: `${GITLAB_HOST}/${GITLAB_PROJECT}.git`,
providerHost: GITLAB_HOST,
},
);
const repositoryLink = testPage.getByTestId("mobile-task-repository-link");
const taskPicker = testPage.getByTestId("mobile-task-picker-trigger");
await expect(testPage.getByTestId("mr-topbar-button")).toBeVisible();
await expect(testPage.getByTestId("remote-executor-status-trigger")).toBeVisible();
await taskPicker.tap();
await expect(session.mobilePortForwardingToggle).toBeVisible();
await expect(session.mobilePortForwardingToggle).toBeEnabled();
await session.mobilePortForwardingToggle.tap();
await expect(session.portForwardButton).toBeVisible();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardDialog.getByRole("button", { name: "Close" }).tap();
await expect(session.portForwardDialog).toBeHidden();
await expect(session.mobilePortForwardingToggle).toBeHidden();
await expect(repositoryLink).toBeVisible();
await expect(taskPicker).toContainText("Narrow action-rich phone header");
const repositoryBox = await repositoryLink.boundingBox();
const taskPickerBox = await taskPicker.boundingBox();
expect(repositoryBox).not.toBeNull();
expect(taskPickerBox).not.toBeNull();
expect(repositoryBox!.width).toBeGreaterThanOrEqual(44);
expect(taskPickerBox!.width).toBeGreaterThanOrEqual(44);
await expectCenterIsReachable(repositoryLink, "Repository link");
await expectCenterIsReachable(taskPicker, "Task picker");
await assertNoDocumentHorizontalOverflow(testPage, "action-rich 320px remote task topbar");
await testPage
.context()
.route(`${GITLAB_HOST}/**`, (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "repository" }),
);
const popupPromise = testPage.waitForEvent("popup");
await repositoryLink.tap();
const popup = await popupPromise;
await expect.poll(() => popup.url()).toBe(`${GITLAB_HOST}/${GITLAB_PROJECT}`);
await popup.close();
await taskPicker.tap();
await expect(testPage.getByRole("dialog", { name: "Tasks" })).toBeVisible();
});
});
@@ -0,0 +1,103 @@
import { test, expect } from "../../fixtures/test-base";
const repositoryOwner = "e2e-topbar-owner";
const repositoryName = "agent-orchestrator";
const repositoryBrowserUrl = `https://github.com/${repositoryOwner}/${repositoryName}`;
const taskTitle = "Explain agent connections";
test.describe("Task topbar remote repository", () => {
// @covers AC-UI-REMOTE-REPO-TOPBAR-001.1, AC-UI-REMOTE-REPO-TOPBAR-001.2
test("opens the repository in a new tab and keeps the task title usable", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, taskTitle, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [
{
remote_url: `${repositoryBrowserUrl}.git`,
provider: "github",
provider_owner: repositoryOwner,
provider_name: repositoryName,
},
],
});
await testPage.goto(`/t/${task.id}`);
const topbar = testPage.getByTestId("task-topbar");
const repositoryLink = topbar.getByRole("link", {
name: `GitHub repository ${repositoryOwner}/${repositoryName}`,
});
await expect(repositoryLink).toHaveAttribute("href", repositoryBrowserUrl);
await expect(repositoryLink).toHaveAttribute("target", "_blank");
await expect(repositoryLink).toHaveAttribute("rel", "noopener noreferrer");
await expect(repositoryLink).toContainText(repositoryName);
await prCapture.screenshot("task-topbar-remote-repository-desktop", {
caption: "Desktop task topbar with its remote repository link before the task title.",
});
const title = topbar.getByTestId("task-topbar-title");
await expect(title).toHaveText(taskTitle);
await title.dblclick();
const renameInput = testPage.getByTestId("task-title-rename-input");
await expect(renameInput).toBeVisible();
await renameInput.press("Escape");
await testPage
.context()
.route("https://github.com/**", (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "repository" }),
);
const popupPromise = testPage.waitForEvent("popup");
await repositoryLink.click();
const popup = await popupPromise;
await expect.poll(() => popup.url()).toBe(repositoryBrowserUrl);
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}$`));
});
test("measures the repository crumb within its visible width cap", async ({
testPage,
apiClient,
seedData,
}) => {
const longRepositoryName =
"agent-orchestrator-with-a-deliberately-long-name-for-breadcrumb-measurement";
const longRepositoryUrl = `https://github.com/${repositoryOwner}/${longRepositoryName}`;
const task = await apiClient.createTask(
seedData.workspaceId,
"Measure a long repository crumb",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [
{
remote_url: `${longRepositoryUrl}.git`,
provider: "github",
provider_owner: repositoryOwner,
provider_name: longRepositoryName,
},
],
},
);
await testPage.goto(`/t/${task.id}`);
const repositoryLink = testPage.getByRole("link", {
name: `GitHub repository ${repositoryOwner}/${longRepositoryName}`,
});
await expect(repositoryLink).toBeVisible();
const visibleWidth = await repositoryLink.evaluate(
(element) => element.getBoundingClientRect().width,
);
const ghostLabel = testPage.locator(`span[data-label="${longRepositoryName}"]`).first();
const measuredWidth = await ghostLabel.evaluate(
(element) => element.parentElement?.getBoundingClientRect().width ?? 0,
);
expect(visibleWidth).toBeLessThanOrEqual(160);
expect(measuredWidth).toBeCloseTo(visibleWidth, 0);
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { remoteRepositoryBrowserUrl } from "./remote-repository-browser-url";
describe("remoteRepositoryBrowserUrl", () => {
it("removes one terminal .git suffix and trailing slash", () => {
expect(remoteRepositoryBrowserUrl("https://gitlab.example.com/team/agent.git/", "gitlab")).toBe(
"https://gitlab.example.com/team/agent",
);
});
it("does not advertise a Bitbucket Server clone URL as its browser page", () => {
expect(
remoteRepositoryBrowserUrl(
"https://bitbucket.example.test/scm/TEAM/fixture.git",
"bitbucket",
),
).toBeNull();
});
it.each([
undefined,
"",
"git@github.com:owner/repo.git",
"http://github.com/owner/repo",
"https://user:secret@github.com/owner/repo",
"https://github.com/owner/repo?tab=code",
"https://github.com/owner/repo#readme",
"not a URL",
])("returns no browser destination for an unsafe or missing URL: %s", (remoteUrl) => {
expect(remoteRepositoryBrowserUrl(remoteUrl, "github")).toBeNull();
});
it("fails closed for plugin providers without a browser URL contract", () => {
expect(
remoteRepositoryBrowserUrl("https://code.example.test/owner/repo.git", "source-control"),
).toBeNull();
});
});
@@ -0,0 +1,38 @@
/**
* Resolve supported first-party HTTPS clone URLs to their matching browser
* pages. Plugin providers need an explicit browser URL contract before their
* clone URL can be used for navigation.
*/
const DIRECT_BROWSER_URL_PROVIDERS = new Set(["github", "gitlab", "azure_devops"]);
export function remoteRepositoryBrowserUrl(
remoteUrl: string | undefined,
provider: string,
): string | null {
if (!DIRECT_BROWSER_URL_PROVIDERS.has(provider)) return null;
const value = remoteUrl?.trim();
if (!value || /[\u0000-\u001f\u007f]/.test(value) || !/^https:\/\/[^/?#]+/i.test(value)) {
return null;
}
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
!url.hostname ||
url.username ||
url.password ||
value.includes("?") ||
value.includes("#")
) {
return null;
}
const path = url.pathname.replace(/\/+$/, "").replace(/\.git$/i, "");
url.pathname = path || "/";
return url.toString();
} catch {
return null;
}
}
+1
View File
@@ -1424,6 +1424,7 @@
"remoteContributionReplaceDescription": "Replace the published PR branch with your unchanged task version. Requires confirmation and an exact provider-head match.",
"remoteContributionUseDescription": "Use the current PR version in this task. Kandev creates a recovery branch first and requires a clean working tree.",
"remoteContributionViewDescription": "Open PR #{{number}} version without changing the task checkout.",
"remoteRepositoryIdentity": "{{provider}} repository {{repository}}",
"replacePRBranchConfirmation": "Replace the published PR branch for {{repository}} at {{head}} with the task version? This changes the provider branch to match the task version.",
"usePRVersionConfirmation": "Use the PR version at {{head}}? Kandev will create a recovery branch for the current task version first. The working tree must be clean.",
"remoteContributionProviderChangedAgain": "The PR changed again. Refresh the provider version and try again.",
+1
View File
@@ -1424,6 +1424,7 @@
"remoteContributionReplaceDescription": "公開済み PR ブランチを、変わってないタスク版で置き換える。確認と、provider-head の完全一致が必要。",
"remoteContributionUseDescription": "このタスクで現在の PR 版を使う。Kandev が先に回復ブランチを作成し、作業ツリーはクリーン必須。",
"remoteContributionViewDescription": "タスクのチェックアウトは変えずに、PR #{{number}} 版を開く。",
"remoteRepositoryIdentity": "{{provider}} リポジトリ {{repository}}",
"replacePRBranchConfirmation": "{{repository}} の公開済み PR ブランチを {{head}} でタスク版に置き換える? これで provider ブランチがタスク版に合う。",
"usePRVersionConfirmation": "{{head}} の PR 版を使う? Kandev は先に今のタスク版の回復ブランチを作る。作業ツリーはクリーン必須。",
"remoteContributionProviderChangedAgain": "PR がまた変わった。provider 版を更新して、もう一度試す。",
+1
View File
@@ -1424,6 +1424,7 @@
"remoteContributionReplaceDescription": "Ŕēƥĺàćē ţĥē ƥũƀĺĩśĥēď ƤŔ ƀŕàńćĥ ŵĩţĥ ŷōũŕ ũńćĥàńĝēď ţàśķ vēŕśĩōń. Ŕēqũĩŕēś ćōńƒĩŕḿàţĩōń àńď àń ēxàćţ ƥŕōvĩďēŕ-ĥēàď ḿàţćĥ.",
"remoteContributionUseDescription": "Ũśē ţĥē ćũŕŕēńţ ƤŔ vēŕśĩōń ĩń ţĥĩś ţàśķ. Ķàńďēv ćŕēàţēś à ŕēćōvēŕŷ ƀŕàńćĥ ƒĩŕśţ àńď ŕēqũĩŕēś à ćĺēàń ŵōŕķĩńĝ ţŕēē.",
"remoteContributionViewDescription": "Ōƥēń ƤŔ #{{number}} vēŕśĩōń ŵĩţĥōũţ ćĥàńĝĩńĝ ţĥē ţàśķ ćĥēćķōũţ.",
"remoteRepositoryIdentity": "{{provider}} ŕēƥōśĩţōŕŷ {{repository}}",
"replacePRBranchConfirmation": "Ŕēƥĺàćē ţĥē ƥũƀĺĩśĥēď ƤŔ ƀŕàńćĥ ƒōŕ {{repository}} àţ {{head}} ŵĩţĥ ţĥē ţàśķ vēŕśĩōń? Ţĥĩś ćĥàńĝēś ţĥē ƥŕōvĩďēŕ ƀŕàńćĥ ţō ḿàţćĥ ţĥē ţàśķ vēŕśĩōń.",
"usePRVersionConfirmation": "Ũśē ţĥē ƤŔ vēŕśĩōń àţ {{head}}? Ķàńďēv ŵĩĺĺ ćŕēàţē à ŕēćōvēŕŷ ƀŕàńćĥ ƒōŕ ţĥē ćũŕŕēńţ ţàśķ vēŕśĩōń ƒĩŕśţ. Ţĥē ŵōŕķĩńĝ ţŕēē ḿũśţ ƀē ćĺēàń.",
"remoteContributionProviderChangedAgain": "Ţĥē ƤŔ ćĥàńĝēď àĝàĩń. Ŕēƒŕēśĥ ţĥē ƥŕōvĩďēŕ vēŕśĩōń àńď ţŕŷ àĝàĩń.",
+1
View File
@@ -1358,6 +1358,7 @@
"remoteContributionReplaceDescription": "Substitui o branch publicado do PR pela versão inalterada da tarefa. Requer confirmação e correspondência exata do commit de referência do fornecedor.",
"remoteContributionUseDescription": "Usa a versão atual do PR nesta tarefa. O Kandev cria primeiro um branch de recuperação e requer uma árvore de trabalho limpa.",
"remoteContributionViewDescription": "Abre a versão do PR #{{number}} sem alterar o checkout da tarefa.",
"remoteRepositoryIdentity": "Repositório {{repository}} no {{provider}}",
"replacePRBranchConfirmation": "Substituir o branch publicado do PR de {{repository}} em {{head}} pela versão da tarefa? Isto altera o branch no fornecedor para corresponder à versão da tarefa.",
"usePRVersionConfirmation": "Usar a versão do PR em {{head}}? O Kandev vai criar primeiro um branch de recuperação para a versão atual da tarefa. A árvore de trabalho tem de estar limpa.",
"remoteContributionProviderChangedAgain": "O PR mudou novamente. Atualize a versão do fornecedor e tente outra vez.",
+1
View File
@@ -1357,6 +1357,7 @@
"remoteContributionReplaceDescription": "用未更改的任务版本替换已发布的 PR 分支。需要确认,并且提供商的最新提交必须完全匹配。",
"remoteContributionUseDescription": "在此任务中使用当前 PR 版本。Kandev 会先创建恢复分支,并且工作区必须干净。",
"remoteContributionViewDescription": "打开 PR #{{number}} 版本,不更改任务工作区。",
"remoteRepositoryIdentity": "{{provider}} 仓库 {{repository}}",
"replacePRBranchConfirmation": "要用任务版本替换 {{repository}} 在 {{head}} 的已发布 PR 分支吗?这会让提供商分支与任务版本一致。",
"usePRVersionConfirmation": "要使用 {{head}} 的 PR 版本吗?Kandev 会先为当前任务版本创建恢复分支。工作区必须干净。",
"remoteContributionProviderChangedAgain": "PR 再次发生更改。请刷新提供商版本后重试。",
+1
View File
@@ -1357,6 +1357,7 @@
"remoteContributionReplaceDescription": "用未更改的任務版本替換已發佈的 PR 分支。需要確認,並且提供者的最新提交必須完全匹配。",
"remoteContributionUseDescription": "在此任務中使用目前 PR 版本。Kandev 會先建立恢復分支,並且工作區必須乾淨。",
"remoteContributionViewDescription": "開啟 PR #{{number}} 版本,不更改任務工作區。",
"remoteRepositoryIdentity": "{{provider}} 儲存庫 {{repository}}",
"replacePRBranchConfirmation": "要用任務版本替換 {{repository}} 在 {{head}} 的已發佈 PR 分支嗎?這會讓提供者分支與任務版本一致。",
"usePRVersionConfirmation": "要使用 {{head}} 的 PR 版本嗎?Kandev 會先為目前任務版本建立恢復分支。工作區必須乾淨。",
"remoteContributionProviderChangedAgain": "PR 再次發生更改。請刷新提供者版本後重試。",
+1
View File
@@ -1357,6 +1357,7 @@
"remoteContributionReplaceDescription": "用未更改的任務版本替換已釋出的 PR 分支。需要確認,並且提供者的最新提交必須完全匹配。",
"remoteContributionUseDescription": "在此任務中使用目前 PR 版本。Kandev 會先建立恢復分支,並且工作區必須乾淨。",
"remoteContributionViewDescription": "開啟 PR #{{number}} 版本,不更改任務工作區。",
"remoteRepositoryIdentity": "{{provider}} 存放庫 {{repository}}",
"replacePRBranchConfirmation": "要用任務版本替換 {{repository}} 在 {{head}} 的已釋出 PR 分支嗎?這會讓提供者分支與任務版本一致。",
"usePRVersionConfirmation": "要使用 {{head}} 的 PR 版本嗎?Kandev 會先為目前任務版本建立恢復分支。工作區必須乾淨。",
"remoteContributionProviderChangedAgain": "PR 再次發生更改。請重新整理提供者版本後重試。",