From 07959cfaea7709a14431821451374478c3e69914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 25 Sep 2026 10:31:38 +0100 Subject: [PATCH] feat: show remote repository in task topbar (#3917) --- .../components/page-topbar-parent-crumb.tsx | 123 ++++++++ apps/web/components/page-topbar.test.tsx | 22 +- apps/web/components/page-topbar.tsx | 84 ++--- .../settings/settings-layout-client.test.tsx | 2 +- ...reate-dialog-remote-repo-provider-tabs.tsx | 19 +- .../task/mobile/session-mobile-layout.tsx | 4 + ...session-mobile-top-bar-repository.test.tsx | 101 +++++- .../task/mobile/session-mobile-top-bar.tsx | 83 ++++- apps/web/components/task/task-layout.tsx | 4 + .../task/task-page-content-helpers.test.ts | 110 ++++++- .../task/task-page-content-helpers.ts | 32 ++ apps/web/components/task/task-page-inner.tsx | 2 + .../web/components/task/task-top-bar.test.tsx | 116 ++++++- apps/web/components/task/task-top-bar.tsx | 41 ++- .../mobile-remote-repository-topbar.spec.ts | 292 ++++++++++++++++++ .../task/remote-repository-topbar.spec.ts | 103 ++++++ .../remote-repository-browser-url.test.ts | 38 +++ .../utils/remote-repository-browser-url.ts | 38 +++ apps/web/src/locales/en/task.json | 1 + apps/web/src/locales/ja/task.json | 1 + apps/web/src/locales/pseudo/task.json | 1 + apps/web/src/locales/pt-pt/task.json | 1 + apps/web/src/locales/zh-cn/task.json | 1 + apps/web/src/locales/zh-hk/task.json | 1 + apps/web/src/locales/zh-tw/task.json | 1 + .../plan.md | 129 ++++++++ .../task-01-linked-remote-repository.md | 139 +++++++++ .../single-remote-repository-task-topbar.md | 55 ++++ .../single-remote-repository-task-topbar.md | 111 +++++++ 29 files changed, 1580 insertions(+), 75 deletions(-) create mode 100644 apps/web/components/page-topbar-parent-crumb.tsx create mode 100644 apps/web/e2e/tests/task/mobile-remote-repository-topbar.spec.ts create mode 100644 apps/web/e2e/tests/task/remote-repository-topbar.spec.ts create mode 100644 apps/web/lib/utils/remote-repository-browser-url.test.ts create mode 100644 apps/web/lib/utils/remote-repository-browser-url.ts create mode 100644 docs/plans/single-remote-repository-task-topbar/plan.md create mode 100644 docs/plans/single-remote-repository-task-topbar/task-01-linked-remote-repository.md create mode 100644 docs/specs/ui/requirements/single-remote-repository-task-topbar.md create mode 100644 docs/specs/ui/system-design/single-remote-repository-task-topbar.md 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.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.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 = ( + + ); + 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 && ( + <> + + + ), + }); + } 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)