diff --git a/apps/web/components/page-topbar-parent-crumb.tsx b/apps/web/components/page-topbar-parent-crumb.tsx
new file mode 100644
index 0000000000..8d8c6336a9
--- /dev/null
+++ b/apps/web/components/page-topbar-parent-crumb.tsx
@@ -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 && (
+
+ {crumb.icon}
+
+ )}
+
+ {crumb.label}
+
+ {crumb.ariaLabel && {crumb.ariaLabel}}
+ >
+ );
+
+ if (crumb.externalUrl) {
+ return (
+
+
+ {body}
+
+
+ );
+ }
+
+ if (crumb.href) {
+ return (
+ <>
+
+
+ {body}
+
+
+ {crumb.phoneOnlyLink && (
+
+ {body}
+
+ )}
+ >
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
+
+export function ParentCrumbMenuItem({ crumb }: { crumb: ParentCrumb }) {
+ if (crumb.externalUrl) {
+ return (
+
+
+ {crumb.icon && (
+
+ {crumb.icon}
+
+ )}
+ {crumb.label}
+
+
+ );
+ }
+
+ if (crumb.href) {
+ return (
+
+ {crumb.label}
+
+ );
+ }
+
+ return {crumb.label};
+}
diff --git a/apps/web/components/page-topbar.test.tsx b/apps/web/components/page-topbar.test.tsx
index 82275ad55e..3f4ed08b2a 100644
--- a/apps/web/components/page-topbar.test.tsx
+++ b/apps/web/components/page-topbar.test.tsx
@@ -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: ,
+ } as unknown as ParentCrumb;
+
+ render();
+
+ 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", () => {
diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx
index e2cfd9990c..4f9641624b 100644
--- a/apps/web/components/page-topbar.tsx
+++ b/apps/web/components/page-topbar.tsx
@@ -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 (
- <>
-
-
- {crumb.label}
-
-
- {crumb.phoneOnlyLink && (
-
- {crumb.label}
-
- )}
- >
- );
- }
- // 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 (
-
- {crumb.label}
-
- );
-}
-
/**
* 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({
- {collapsed.map((p) =>
- p.href ? (
-
- {p.label}
-
- ) : (
-
- {p.label}
-
- ),
- )}
+ {collapsed.map((p, index) => (
+
+ ))}
@@ -370,6 +325,15 @@ function GhostLabel({ label, className }: { label: string; className?: string })
return ;
}
+function GhostParentLabel({ crumb }: { crumb: ParentCrumb }) {
+ return (
+
+ {crumb.icon && }
+
+
+ );
+}
+
type TopbarGhostProps = {
ghostRef: RefObject;
backHref: string;
@@ -426,7 +390,7 @@ function TopbarGhost({
{lead}
{chain.map((p, index) => (
-
+
))}
@@ -441,7 +405,7 @@ function TopbarGhost({
)}
{lastParent && (
<>
-
+
>
)}
diff --git a/apps/web/components/settings/settings-layout-client.test.tsx b/apps/web/components/settings/settings-layout-client.test.tsx
index cafb3409d6..30e15bee57 100644
--- a/apps/web/components/settings/settings-layout-client.test.tsx
+++ b/apps/web/components/settings/settings-layout-client.test.tsx
@@ -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();
});
diff --git a/apps/web/components/task-create-dialog-remote-repo-provider-tabs.tsx b/apps/web/components/task-create-dialog-remote-repo-provider-tabs.tsx
index ae47748baf..529bed81ec 100644
--- a/apps/web/components/task-create-dialog-remote-repo-provider-tabs.tsx
+++ b/apps/web/components/task-create-dialog-remote-repo-provider-tabs.tsx
@@ -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 = {
};
export function RemoteRepositoryProviderIcon({ provider }: { provider: RemoteRepositoryProvider }) {
+ const registry = usePluginRegistry();
if (provider === "github") return ;
if (provider === "gitlab") return ;
if (provider === "azure_devops") return ;
- const registration = pluginRegistry.getRepositoryProvider(provider);
+ const registration = registry.getRepositoryProvider(provider);
const Icon = registration ? resolvePluginIcon(registration.icon) : IconGitBranch;
return ;
}
-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 = (
{
+ 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 ;
+}
+
+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 = {}) {
return render(
@@ -60,6 +85,78 @@ function renderTopBar(props: Record = {}) {
}
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" });
diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx
index 92b277e630..1245e060f9 100644
--- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx
+++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx
@@ -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 = (
+
+
+
+ );
+ const name = (
+
+ {repository.displayName}
+
+ );
+ 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 = (
+
+ {icon}
+ {name}
+
+ );
+
+ if (repository.browserUrl) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+ {accessibleName}
+
+ );
+}
+
export function MobileRemoteExecutorIndicator({
taskId,
sessionId,
@@ -283,9 +353,18 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
+ {props.topbarRepository && (
+ <>
+
+
+ >
+ )}
{
});
});
-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(
{
diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts
index 70c61c175e..1331a21c74 100644
--- a/apps/web/components/task/task-page-content-helpers.ts
+++ b/apps/web/components/task/task-page-content-helpers.ts
@@ -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,
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),
+ };
+}
diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx
index e94c8241a9..c185d2ff93 100644
--- a/apps/web/components/task/task-page-inner.tsx
+++ b/apps/web/components/task/task-page-inner.tsx
@@ -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,
diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx
index d2993ad501..357a267b8c 100644
--- a/apps/web/components/task/task-top-bar.test.tsx
+++ b/apps/web/components/task/task-top-bar.test.tsx
@@ -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 ;
+}
+
+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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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", () => {
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx
index cb1257740d..6ff0e3d9c4 100644
--- a/apps/web/components/task/task-top-bar.tsx
+++ b/apps/web/components/task/task-top-bar.tsx
@@ -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 (
+
+
+ ),
+ });
+ } else if (repositoryLabel) {
+ crumbs.push({ label: repositoryLabel });
+ }
return crumbs.length > 0 ? crumbs : undefined;
}
diff --git a/apps/web/e2e/tests/task/mobile-remote-repository-topbar.spec.ts b/apps/web/e2e/tests/task/mobile-remote-repository-topbar.spec.ts
new file mode 100644
index 0000000000..6caf30a1ff
--- /dev/null
+++ b/apps/web/e2e/tests/task/mobile-remote-repository-topbar.spec.ts
@@ -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();
+ 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, 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>>;
+ };
+ };
+ 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();
+ });
+});
diff --git a/apps/web/e2e/tests/task/remote-repository-topbar.spec.ts b/apps/web/e2e/tests/task/remote-repository-topbar.spec.ts
new file mode 100644
index 0000000000..564bcddddc
--- /dev/null
+++ b/apps/web/e2e/tests/task/remote-repository-topbar.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/utils/remote-repository-browser-url.test.ts b/apps/web/lib/utils/remote-repository-browser-url.test.ts
new file mode 100644
index 0000000000..9b7a8e36b4
--- /dev/null
+++ b/apps/web/lib/utils/remote-repository-browser-url.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/lib/utils/remote-repository-browser-url.ts b/apps/web/lib/utils/remote-repository-browser-url.ts
new file mode 100644
index 0000000000..ee12d66962
--- /dev/null
+++ b/apps/web/lib/utils/remote-repository-browser-url.ts
@@ -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;
+ }
+}
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index 68b86e2ef4..7fc2e6426f 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -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.",
diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json
index ebd1d16805..07fdd643f6 100644
--- a/apps/web/src/locales/ja/task.json
+++ b/apps/web/src/locales/ja/task.json
@@ -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 版を更新して、もう一度試す。",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index db3704e93b..cead72e841 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -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ēŕśĩōń àńď ţŕŷ àĝàĩń.",
diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json
index cf91e8e8a3..5e185cf25c 100644
--- a/apps/web/src/locales/pt-pt/task.json
+++ b/apps/web/src/locales/pt-pt/task.json
@@ -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.",
diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json
index 133a472d85..743fdf9b91 100644
--- a/apps/web/src/locales/zh-cn/task.json
+++ b/apps/web/src/locales/zh-cn/task.json
@@ -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 再次发生更改。请刷新提供商版本后重试。",
diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json
index c89cb0fcc6..1a3b806862 100644
--- a/apps/web/src/locales/zh-hk/task.json
+++ b/apps/web/src/locales/zh-hk/task.json
@@ -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 再次發生更改。請刷新提供者版本後重試。",
diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json
index eea41f2d3c..37c2a379ec 100644
--- a/apps/web/src/locales/zh-tw/task.json
+++ b/apps/web/src/locales/zh-tw/task.json
@@ -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 再次發生更改。請重新整理提供者版本後重試。",
diff --git a/docs/plans/single-remote-repository-task-topbar/plan.md b/docs/plans/single-remote-repository-task-topbar/plan.md
new file mode 100644
index 0000000000..f961571125
--- /dev/null
+++ b/docs/plans/single-remote-repository-task-topbar/plan.md
@@ -0,0 +1,129 @@
+---
+created: 2026-09-24
+status: complete
+requirements:
+ - REQ-UI-REMOTE-REPO-TOPBAR-001
+system_design:
+ - ../../specs/ui/system-design/single-remote-repository-task-topbar.md
+legacy_specs: []
+---
+
+# Implementation Plan: Single remote repository task topbar
+
+## Overview
+
+Show a provider icon and short repository name before the task title when the
+task has exactly one remote repository. Make the repository a direct external
+link when its saved URL is safe for browser navigation. One work order carries
+the derived data through both topbars and verifies the complete interaction.
+
+## Scope
+
+### In scope
+
+- Desktop and phone task topbar presentation for exactly one remote repository.
+- Provider icon, short name, browser link, accessible name, and safe URL
+ fallback.
+- Focused unit and browser coverage for eligible and ineligible cases.
+
+### Out of scope
+
+- Repository metadata migration or API changes.
+- Task card, filter, sidebar, and multi-repository label changes.
+- General redesign of task or mobile topbars.
+
+## Technical approach
+
+Add a topbar-only descriptor in
+`apps/web/components/task/task-page-content-helpers.ts::resolveTaskProps`,
+using `Task.repositories` and the resolved `Repository`. Pass it through
+`task-page-inner.tsx` and `task-layout.tsx`. Keep `repositorySlug` and
+`repositoryLabel` unchanged for other consumers.
+
+`task-top-bar.tsx` uses the descriptor for its final parent crumb.
+`page-topbar.tsx` renders an optional provider icon and external anchor while
+retaining truncation and collapsed crumbs. Reuse
+`RemoteRepositoryProviderIcon` for first-party and plugin providers.
+`mobile/session-mobile-top-bar.tsx` gives the remote repository its own link
+before the existing task-picker button. Add one localized accessible-name key
+in all required catalogs. Put safe HTTPS-to-browser URL normalization in a
+focused helper with URL and fallback tests.
+
+## ASCII UI preview
+
+`UI-01: Desktop task topbar`, task detail with one remote repository.
+The repo link and task title are separate controls; the project ancestor, if
+present, remains before the repository. Illustrative spacing only.
+
+```text
+Current: [Untrivial-ai/agent-orch...] > [Explain agent connections]
+Planned: [GitHub icon agent-orchestrator] > [Explain agent connections]
+ repo link editable task title
+```
+
+`UI-02: Phone task topbar`, same task. This is one scroll-free header row;
+the task picker keeps its existing branch/diff summary on its second line.
+The repository link is a separate touch target.
+
+```text
+[Back] [GitHub icon agent-...] > [Explain agent... v] [Actions]
+ branch / diff summary
+```
+
+When the remote URL is unusable, the repository label and icon remain static.
+For local, multiple, or zero repositories, the current header composition
+remains. The icon, order, link, fallback, and reachable task title are
+requirements (`AC-UI-REMOTE-REPO-TOPBAR-001.1` to `.5`); spacing and shown
+truncation lengths are illustrative. Rendered checks cover both views.
+
+## Tests
+
+- `apps/web/components/task/task-page-content-helpers.test.ts` proves remote
+ eligibility, short names, and unchanged labels (`AC-...001.1`, `.3`).
+- A focused browser-URL helper test proves accepted HTTPS and rejected
+ credential, SSH, malformed, or missing URLs (`AC-...001.2`, `.4`).
+- `apps/web/components/task/task-top-bar.test.tsx` and
+ `apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx`
+ prove provider icon, anchor attributes, fallback, and separate task action
+ (`AC-...001.1` through `.5`).
+
+## E2E tests
+
+- `apps/web/e2e/tests/task/remote-repository-topbar.spec.ts` (chromium) opens
+ a task created with one `repositories` entry containing an HTTPS
+ `remote_url`, checks the actual browser link and short label, and confirms
+ the task title remains editable (`AC-...001.1`, `.2`).
+- `apps/web/e2e/tests/task/mobile-remote-repository-topbar.spec.ts`
+ (mobile-chrome) checks the same destination, touch activation, picker,
+ truncation, and viewport containment (`AC-...001.5`).
+
+## Work orders
+
+- [x] [Task 01: Render a linked single remote repository](task-01-linked-remote-repository.md)
+
+## Verification results
+
+Passed final frontend validation after review remediation:
+
+- Vitest: six focused files, 102 tests passed. Coverage includes Bitbucket
+ Server's unsupported browser-route fallback and plugin provider registration
+ and unregistration after desktop and phone headers mount.
+- `pnpm run typecheck` and `pnpm run i18n:check` passed.
+- Targeted ESLint passed with no warnings.
+- `pnpm run build` produced the production Vite bundle.
+- Desktop and `mobile-chrome` Playwright scenarios passed. Both E2E runs built
+ the backend and frontend. The Pixel 5 check verified 44px controls,
+ truncation, no horizontal overflow, external navigation, and task picking;
+ the mobile composition was visually inspected during the implementation run.
+- Specification validation and lint passed.
+
+## Risks
+
+- A clone URL is not always a browser page, especially for plugin code hosts.
+ Only GitHub, GitLab, and Azure DevOps are mapped directly; Bitbucket Server's
+ `/scm/TEAM/fixture.git` clone route differs from its
+ `/projects/TEAM/repos/fixture` browser route. Plugin providers stay static
+ until their contract supplies an authoritative browser URL.
+- Long repository and task names compete for the phone header. The phone
+ rendered check and Playwright geometry assertions must confirm both controls
+ stay usable.
diff --git a/docs/plans/single-remote-repository-task-topbar/task-01-linked-remote-repository.md b/docs/plans/single-remote-repository-task-topbar/task-01-linked-remote-repository.md
new file mode 100644
index 0000000000..8f6f91de80
--- /dev/null
+++ b/docs/plans/single-remote-repository-task-topbar/task-01-linked-remote-repository.md
@@ -0,0 +1,139 @@
+---
+id: "01-linked-remote-repository"
+title: "Render a linked single remote repository"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-REMOTE-REPO-TOPBAR-001
+acceptance_criteria:
+ - AC-UI-REMOTE-REPO-TOPBAR-001.1
+ - AC-UI-REMOTE-REPO-TOPBAR-001.2
+ - AC-UI-REMOTE-REPO-TOPBAR-001.3
+ - AC-UI-REMOTE-REPO-TOPBAR-001.4
+ - AC-UI-REMOTE-REPO-TOPBAR-001.5
+system_design:
+ - ../../specs/ui/system-design/single-remote-repository-task-topbar.md
+---
+
+# Task 01: Render a linked single remote repository
+
+## Summary
+
+Derive one remote repository's display and browser destination from the
+existing task and repository records. Render its provider icon, short name,
+and link in both task topbars while preserving the task title action.
+
+## In scope
+
+- Topbar-only repository descriptor and safe browser URL helper.
+- Desktop breadcrumb and phone header link, including provider icons and
+ accessible names.
+- Required locale keys and focused unit and Playwright coverage.
+
+## Out of scope
+
+- Changing stored repository data, task card labels, filters, or
+ multi-repository selection.
+
+## Acceptance
+
+1. Exactly one remote repository renders as provider icon plus short name
+ before the task title, with a separate usable task-title action on desktop
+ and phone.
+2. A validated HTTPS clone URL for a supported first-party provider whose
+ path maps to its browser page opens in a new tab. Missing, unsafe, or
+ unsupported plugin-provider destinations produce a static label, with no
+ broken link.
+3. No-, local-, and multi-repository tasks retain their current topbar
+ behavior. The phone link stays touch-reachable without horizontal overflow.
+
+## ASCII UI preview
+
+`UI-01: Desktop task topbar` and `UI-02: Phone task topbar` from the
+[full preview](plan.md#ascii-ui-preview) apply to this work order
+(`AC-UI-REMOTE-REPO-TOPBAR-001.1` to `.5`).
+
+```text
+Desktop: [GitHub icon agent-orchestrator] > [Explain agent connections]
+Phone: [Back] [GitHub icon agent-...] > [Explain agent... v] [Actions]
+ branch / diff summary
+```
+
+The repo link and task action are independent controls. For a missing browser
+URL, the repository label is static. Spacing and truncation are illustrative.
+
+## Verification
+
+```bash
+(cd apps/web && pnpm exec vitest run components/page-topbar.test.tsx components/task/task-page-content-helpers.test.ts components/task/task-top-bar.test.tsx components/task/mobile/session-mobile-top-bar-repository.test.tsx components/task-create-dialog-remote-repo-provider-tabs.test.tsx lib/utils/remote-repository-browser-url.test.ts)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm run i18n:check)
+(cd apps/web && pnpm run build)
+(cd apps/web && pnpm e2e:run tests/task/remote-repository-topbar.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-remote-repository-topbar.spec.ts)
+```
+
+If this is a fresh worktree, first run `(cd apps && pnpm install
+--frozen-lockfile)` once.
+
+Targeted lint and design-package validation:
+
+```bash
+(cd apps/web && pnpm exec eslint components/page-topbar.tsx components/page-topbar-parent-crumb.tsx components/page-topbar.test.tsx components/task-create-dialog-remote-repo-provider-tabs.tsx components/task/task-page-content-helpers.ts components/task/task-page-content-helpers.test.ts components/task/task-top-bar.tsx components/task/task-top-bar.test.tsx components/task/mobile/session-mobile-top-bar.tsx components/task/mobile/session-mobile-top-bar-repository.test.tsx lib/utils/remote-repository-browser-url.ts lib/utils/remote-repository-browser-url.test.ts)
+python3 scripts/list-docs.py validate
+python3 scripts/lint-spec-files.py --all
+```
+
+## Files likely touched
+
+- `apps/web/components/task/task-page-content-helpers.ts`
+- `apps/web/components/task/task-page-inner.tsx`
+- `apps/web/components/task/task-layout.tsx`
+- `apps/web/components/task/task-top-bar.tsx`
+- `apps/web/components/page-topbar.tsx`
+- `apps/web/components/page-topbar-parent-crumb.tsx`
+- `apps/web/components/task/mobile/session-mobile-layout.tsx`
+- `apps/web/components/task/mobile/session-mobile-top-bar.tsx`
+- `apps/web/lib/utils/remote-repository-browser-url.ts` (new)
+- `apps/web/src/locales/*/task.json`
+- Focused existing unit tests and two task Playwright specs named above.
+
+## Dependencies
+
+None.
+
+## Risks
+
+- The repo name and title may collide on narrow phones; verify real element
+ bounds and title reachability.
+- Plugin hosts such as Bitbucket Server can use clone routes such as
+ `/scm/TEAM/fixture.git` while their browser route is `/projects/TEAM/repos/fixture`.
+ Keep these providers static until a contract supplies the authoritative
+ browser URL.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirement](../../specs/ui/requirements/single-remote-repository-task-topbar.md)
+- [System design](../../specs/ui/system-design/single-remote-repository-task-topbar.md)
+- Existing task topbar, page topbar, mobile topbar, and repository-provider
+ icon patterns.
+
+## Results
+
+Passed after review remediation. Six focused Vitest files reported 102 passing
+tests, including the Bitbucket Server clone-route fallback and desktop/phone
+provider registration and unregistration after mount. Frontend typecheck,
+i18n validation, targeted ESLint, the production Vite build, specification
+validation, and spec lint passed. The desktop Playwright scenario verified the
+repository link, new-tab navigation, and title rename action. The Pixel 5
+scenario verified the repository link, picker, 44px targets, truncation, and
+no horizontal overflow. Both E2E scenarios passed with successful frontend and
+backend builds. The phone structure was visually inspected in the earlier
+implementation run; this remediation only changes provider metadata updates
+and unsupported-provider link eligibility.
diff --git a/docs/specs/ui/requirements/single-remote-repository-task-topbar.md b/docs/specs/ui/requirements/single-remote-repository-task-topbar.md
new file mode 100644
index 0000000000..c265828913
--- /dev/null
+++ b/docs/specs/ui/requirements/single-remote-repository-task-topbar.md
@@ -0,0 +1,55 @@
+---
+status: draft
+system: ui
+created: 2026-09-24
+owners:
+ - kandev
+---
+
+# Single remote repository task topbar requirements
+
+## Overview
+
+The task topbar identifies a task's repository, but its current `owner/repo`
+label takes space and does not open the code host. UI owns this presentation and
+navigation affordance. The workspace system remains the source of repository
+identity, provider, and remote URL.
+
+## Requirements
+
+### REQ-UI-REMOTE-REPO-TOPBAR-001: Remote repository breadcrumb
+
+**Intent:** A user working in a task with one remote repository can recognize
+the repository and open it directly from the task topbar.
+
+#### Acceptance criteria
+
+- **AC-UI-REMOTE-REPO-TOPBAR-001.1:** When a task is linked to exactly one
+ remote repository, its topbar shall present the external provider icon,
+ repository name without its organization or owner prefix, a separator, and
+ the task name, in that order. The task name shall retain its current rename
+ or task-picker action.
+- **AC-UI-REMOTE-REPO-TOPBAR-001.2:** The repository icon and name shall form
+ one keyboard-accessible link to the repository's browser page. Activating it
+ shall open the external page in a new tab without replacing the task page.
+ The link's accessible name shall identify the repository and provider.
+- **AC-UI-REMOTE-REPO-TOPBAR-001.3:** A task with no repository, a local
+ repository, or more than one repository shall retain its current topbar
+ repository presentation and navigation behavior. Repository labels in task
+ cards, filters, and other surfaces shall retain their existing identities.
+- **AC-UI-REMOTE-REPO-TOPBAR-001.4:** When a remote repository has no usable
+ browser URL, the topbar shall still show its provider icon and short name,
+ but shall not present an inert or unsafe link.
+- **AC-UI-REMOTE-REPO-TOPBAR-001.5:** On a phone, the same repository
+ label shall be visible beside the task picker, and its link shall be
+ touch-reachable when a browser URL is available.
+ Long names shall truncate without hiding the task picker or causing
+ horizontal page overflow. The full repository identity shall remain
+ available to assistive technology.
+
+## Out of scope
+
+- Changing stored repository names, task names, provider identities, or clone
+ URLs.
+- Changing the multi-repository selector or any repository label outside the
+ task topbar.
diff --git a/docs/specs/ui/system-design/single-remote-repository-task-topbar.md b/docs/specs/ui/system-design/single-remote-repository-task-topbar.md
new file mode 100644
index 0000000000..8769e677c5
--- /dev/null
+++ b/docs/specs/ui/system-design/single-remote-repository-task-topbar.md
@@ -0,0 +1,111 @@
+---
+status: draft
+system: ui
+requirements:
+ - REQ-UI-REMOTE-REPO-TOPBAR-001
+---
+
+# Single remote repository task topbar system design
+
+## Purpose and boundaries
+
+The task detail projection already receives a `Repository` with `source_type`,
+`provider`, `provider_owner`, `provider_name`, and `remote_url`. This design
+changes only task topbar presentation. `repositorySlug` continues to provide
+the stable `owner/repo` identity used by task cards and filters.
+
+## Requirement mapping
+
+| Requirement | Design sections |
+| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| `REQ-UI-REMOTE-REPO-TOPBAR-001` | Repository projection, Desktop breadcrumb, Phone composition, Browser URL and fallback, Verification |
+
+## Repository projection
+
+`resolveTaskProps` in `apps/web/components/task/task-page-content-helpers.ts`
+keeps `repositoryLabel` for existing consumers. It additionally derives a
+topbar-only descriptor when `task.repositories` contains exactly one entry,
+the resolved primary `Repository` matches that entry, and its `source_type` is
+`provider` (the persisted source type for a provider-backed remote repository).
+The descriptor carries the short display name (`provider_name`, then
+`name` as fallback), full `repositorySlug` for accessible context, provider,
+and a validated browser URL or `null`. Do not infer eligibility from the active
+session or a slash in the label. Keep this descriptor ephemeral; no API,
+persistence, or workspace contract changes are needed.
+
+`task-page-inner.tsx` passes the descriptor to the desktop `TaskTopBar` and,
+through `TaskLayout`, to `SessionMobileTopBar`. Existing `repositoryLabel`
+continues to serve local and multi-repository paths.
+
+## Desktop breadcrumb
+
+`TaskTopBar` builds the existing project ancestor first. For an eligible
+repository, its final parent crumb uses the short name, provider icon, and
+external URL. The task title stays the current editable title crumb. Extend
+`PageTopbar`'s `ParentCrumb` rendering narrowly to support an optional icon and
+external-link attributes. Preserve the existing truncation, project ancestor,
+and collapsed-parent behavior, including icon and link when the repository is
+the last visible parent. External links use native `` behavior with
+`target="_blank"` and `rel="noopener noreferrer"`.
+
+Use `RemoteRepositoryProviderIcon` so GitHub, GitLab, Azure DevOps, and
+registered plugin providers use their existing semantic icon. A provider
+without a registered icon uses the current generic repository fallback.
+
+## Phone composition
+
+The nearest shipped exemplar is `SessionMobileTopBar`: it has a title-triggered
+task picker and a second line for branch context. Keep the back control and
+task picker. For the eligible single remote repository, render the provider
+icon and short repository name as a separate link before the task picker, with
+the separator between them. The picker retains the task name, chevron, and
+branch/diff summary. The link must not be nested inside the picker button.
+Constrain the repository link before the task title, preserve the existing
+header's single scroll-free row, and give the link at least a 44px touch
+target. No drawer or saved mobile preference is needed for a direct external
+destination. Local and multi-repository tasks keep the current secondary-line
+repository label. The same descriptor and URL decision serve both viewports.
+
+## Browser URL and fallback
+
+Use the saved `remote_url` as the source of the browser destination only for
+the first-party providers `github`, `gitlab`, and `azure_devops`, whose clone
+URL path maps to the repository browser page. Accept only an HTTPS URL with a
+hostname, no username/password, query, or fragment; remove one terminal `.git`
+suffix and trailing slash from its path. Do not guess a website URL from an
+SSH/git locator or synthesize plugin-specific routes. The repository-provider
+plugin contract supplies a clone URL but no browser-page URL, so plugin
+providers render a static crumb unless that contract later adds an authoritative
+browser destination. For example, Bitbucket Server's
+`/scm/TEAM/fixture.git` clone route does not identify its
+`/projects/TEAM/repos/fixture` browser page. This also prevents a credential or
+unsupported scheme from becoming a topbar link. The backend remains responsible
+for the stored credential-free clone URL; the UI validates the navigation
+target at render time.
+
+The link's visible label is short, while its localized accessible name names
+the provider and full repository identity. The icon is decorative inside that
+named link. Add copy in all required locale catalogs.
+
+## Verification
+
+Unit tests cover exactly-one/remote eligibility, local and multi-repository
+fallback, short name, provider icon, valid and invalid URL handling, the
+Bitbucket Server clone-route fallback, plugin-provider fallback, registry
+changes after mount on desktop and phone, and the external anchor attributes.
+Desktop and mobile Playwright scenarios assert that a supported first-party
+remote link has the expected browser URL while the task title remains usable.
+The phone scenario also checks a touch target of at least 44px, truncation, and
+no horizontal document overflow. A focused rendered check at desktop and Pixel
+5 widths compares the final structure with the plan preview.
+
+## Related contracts
+
+- [Mobile task chrome](mobile-task-chrome.md) owns the existing phone task
+ picker and compact header.
+- [Task navigation](task-navigation.md) owns first-party task links; this
+ external repository link retains browser navigation.
+
+## Implementation plans
+
+- [Single remote repository task topbar](../../../plans/single-remote-repository-task-topbar/plan.md)