feat: show remote repository in task topbar (#3917)
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 版を更新して、もう一度試す。",
|
||||
|
||||
@@ -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ēŕśĩōń àńď ţŕŷ àĝàĩń.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 再次发生更改。请刷新提供商版本后重试。",
|
||||
|
||||
@@ -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 再次發生更改。請刷新提供者版本後重試。",
|
||||
|
||||
@@ -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 再次發生更改。請重新整理提供者版本後重試。",
|
||||
|
||||
Reference in New Issue
Block a user