From 9ef80367f8e3677deaaa7f1c99e22d4c7c7dcd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 22 Aug 2026 13:58:07 +0100 Subject: [PATCH] feat(web): name each task's repository in the sidebar and task header (#2917) Co-authored-by: Kandev Agent --- apps/web/components/page-topbar.tsx | 9 ++- .../task/mobile/session-mobile-layout.tsx | 5 ++ ...session-mobile-top-bar-repository.test.tsx | 81 +++++++++++++++++++ .../task/mobile/session-mobile-top-bar.tsx | 40 ++++++--- .../components/task/task-archived-context.tsx | 7 +- .../task/task-item-repository.test.tsx | 49 +++++++++++ .../components/task/task-item-stats-row.tsx | 34 ++++++-- apps/web/components/task/task-item.tsx | 30 ++++--- .../task/task-layout-repository.test.tsx | 40 +++++++++ apps/web/components/task/task-layout.tsx | 4 + .../task/task-page-content-helpers.test.ts | 70 ++++++++++++++++ .../task/task-page-content-helpers.ts | 9 ++- apps/web/components/task/task-page-inner.tsx | 2 + .../task-session-sidebar-archived-item.ts | 2 +- .../web/components/task/task-switcher-row.tsx | 7 +- .../components/task/task-switcher.test.tsx | 43 ++++++++++ apps/web/components/task/task-switcher.tsx | 10 +++ .../web/components/task/task-top-bar.test.tsx | 31 ++++++- apps/web/components/task/task-top-bar.tsx | 29 ++++++- .../web/lib/sidebar/apply-view-labels.test.ts | 30 ++++++- apps/web/lib/sidebar/apply-view.ts | 10 ++- 21 files changed, 505 insertions(+), 37 deletions(-) create mode 100644 apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx create mode 100644 apps/web/components/task/task-item-repository.test.tsx create mode 100644 apps/web/components/task/task-layout-repository.test.tsx diff --git a/apps/web/components/page-topbar.tsx b/apps/web/components/page-topbar.tsx index 13d0b94922..7265bd946b 100644 --- a/apps/web/components/page-topbar.tsx +++ b/apps/web/components/page-topbar.tsx @@ -270,9 +270,14 @@ function ParentCrumbLabel({ crumb }: { crumb: ParentCrumb }) { ); } - // Static crumb: orientation only, dimmed so it does not read as a link. + // Static crumb: orientation only, dimmed so it does not read as a link. The + // `title` is what makes a name longer than `max-w-40` readable at all, since + // truncation is the only thing standing between a long label and a squeezed + // page title. return ( - {crumb.label} + + {crumb.label} + ); } diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 98a62ab78d..effadc440e 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -80,6 +80,8 @@ type SessionMobileLayoutProps = { baseBranch?: string; worktreeBranch?: string | null; taskTitle?: string; + /** `owner/repo` (or the repository name) of the task's primary repository. */ + repositoryLabel?: string | null; isRemoteExecutor?: boolean; remoteExecutorType?: string | null; remoteExecutorName?: string | null; @@ -352,6 +354,8 @@ type MobileTopBarStickyProps = { activeTaskId: string | null; workspaceId: string | null; taskTitle?: string; + /** `owner/repo` (or the repository name) of the task's primary repository. */ + repositoryLabel?: string | null; effectiveSessionId: string | null; baseBranch?: string; worktreeBranch?: string | null; @@ -378,6 +382,7 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) { taskId={props.activeTaskId} workspaceId={props.workspaceId} taskTitle={props.taskTitle} + repositoryLabel={props.repositoryLabel} sessionId={props.effectiveSessionId} baseBranch={props.baseBranch} worktreeBranch={props.worktreeBranch} diff --git a/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx b/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx new file mode 100644 index 0000000000..770779bbed --- /dev/null +++ b/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { SessionMobileTopBar } from "./session-mobile-top-bar"; + +afterEach(cleanup); + +// The phone bar's git machinery is not what this file is about; stub the two +// hooks that reach for session state so the header renders standalone. +vi.mock("@/hooks/domains/session/use-session-git-status", () => ({ + useSessionGitStatus: () => ({ files: [] }), + useSessionGitStatusByRepo: () => [], +})); + +vi.mock("@/hooks/domains/session/use-session-commits", () => ({ + useSessionCommits: () => ({ commits: [] }), +})); + +vi.mock("@/hooks/domains/session/use-remote-contribution-relation", () => ({ + useRemoteContributionRelation: () => ({ + relation: { action: "none", canPull: false, canReplaceRemote: false, canUseRemote: false }, + }), +})); + +// Trailing controls the header composes but this file does not exercise. +vi.mock("@/components/task/port-forward-dialog", () => ({ + PortForwardButton: () => null, +})); + +vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({ + TaskTopBarPluginActions: () => null, +})); + +vi.mock("@/components/gitlab/mr-topbar-button", () => ({ + MRTopbarButton: () => null, +})); + +const REPOSITORY_TEST_ID = "mobile-task-repository"; + +function renderTopBar(props: Record = {}) { + return render( + + + + + + + , + ); +} + +describe("SessionMobileTopBar repository", () => { + it("names the task's repository, so the phone header says which project this is", () => { + renderTopBar({ repositoryLabel: "kdlbs/kandev" }); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe("kdlbs/kandev"); + }); + + it("keeps the full repository name reachable on hover when it is truncated", () => { + renderTopBar({ repositoryLabel: "acme-platform/infra-terraform-modules" }); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).getAttribute("title")).toBe( + "acme-platform/infra-terraform-modules", + ); + }); + + it("renders nothing when the task has no repository", () => { + renderTopBar(); + + expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull(); + }); +}); 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 a67bf6f7b2..477c05d530 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -38,6 +38,8 @@ type SessionMobileTopBarProps = { taskId?: string | null; workspaceId?: string | null; taskTitle?: string; + /** `owner/repo` (or the repository name) of the task's primary repository. */ + repositoryLabel?: string | null; sessionId?: string | null; baseBranch?: string; worktreeBranch?: string | null; @@ -56,11 +58,13 @@ type SessionMobileTopBarProps = { function MobileTaskTitle({ taskTitle, + repositoryLabel, displayBranch, totalAdditions, totalDeletions, }: { taskTitle?: string; + repositoryLabel?: string | null; displayBranch?: string; totalAdditions: number; totalDeletions: number; @@ -69,15 +73,32 @@ function MobileTaskTitle({ return (
{taskTitle ?? t("task:taskDetails")} - {displayBranch && ( -
- - {displayBranch} - {(totalAdditions > 0 || totalDeletions > 0) && ( - - )} -
- )} + {/* The phone bar has no breadcrumb, so the repository rides the same + secondary line as the branch. It shrinks first: on a phone the branch + and diff stats are the denser signal, and the full name stays in + `title`. */} +
+ {repositoryLabel && ( + + {repositoryLabel} + + )} + {displayBranch && ( + <> + + + {displayBranch} + + {(totalAdditions > 0 || totalDeletions > 0) && ( + + )} + + )} +
); } @@ -494,6 +515,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar( cleanup()); + +const REPOSITORY_TEST_ID = "sidebar-task-repository"; +const PRIMARY_REPOSITORY = "kdlbs/kandev"; + +function renderTaskItem(props: Partial> = {}) { + return render( + + + + + , + ); +} + +describe("TaskItem repository label", () => { + it("names the repository the task belongs to", () => { + renderTaskItem({ repositoryPath: PRIMARY_REPOSITORY }); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(PRIMARY_REPOSITORY); + }); + + it("keeps the full repository name reachable on hover when it is truncated", () => { + renderTaskItem({ repositoryPath: "a-very-long-org-name/a-very-long-repository-name" }); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).getAttribute("title")).toBe( + "a-very-long-org-name/a-very-long-repository-name", + ); + }); + + it("omits the repository when the list already groups by it", () => { + renderTaskItem({ repositoryPath: PRIMARY_REPOSITORY, showRepository: false }); + + expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull(); + }); + + it("renders nothing when the task has no repository", () => { + renderTaskItem({}); + + expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-item-stats-row.tsx b/apps/web/components/task/task-item-stats-row.tsx index 6ce53c73f9..160d29ea05 100644 --- a/apps/web/components/task/task-item-stats-row.tsx +++ b/apps/web/components/task/task-item-stats-row.tsx @@ -25,18 +25,39 @@ const POLL_MODE_CONFIG: Record< }; /** - * The sidebar row's metadata line: relative last-update time, PR number, + * The repository a task belongs to. The one element of the stats row allowed to + * shrink: a long `owner/repo` truncates rather than pushing the PR number and + * badges out of the row, and `title` keeps the full name reachable on hover. + */ +function RepositoryLabel({ label }: { label?: string }) { + if (!label) return null; + return ( + + {label} + + ); +} + +/** + * The sidebar row's metadata line: relative last-update time, repository, PR number, * queued-prompt mail badge, WIP queue icon, and (debug UI only) the session * poll-mode letter. */ export function TaskItemStatsRow({ updatedAt, + repositoryLabel, prInfo, primarySessionId, queuedCount, wipQueue, }: { updatedAt?: string; + /** Repository the task belongs to; already resolved to a slug or name. */ + repositoryLabel?: string; prInfo?: { number: number; state: string; aggregateState?: string }; primarySessionId?: string | null; queuedCount?: number; @@ -49,7 +70,9 @@ export function TaskItemStatsRow({ : null, ); - if (!updatedAt && !prInfo && !pollMode && !queuedCount && !wipQueue) return null; + if (!updatedAt && !repositoryLabel && !prInfo && !pollMode && !queuedCount && !wipQueue) { + return null; + } const modeConfig = pollMode ? POLL_MODE_CONFIG[pollMode] : null; const modeLabel = modeConfig ? t(modeConfig.labelKey) : ""; @@ -62,17 +85,18 @@ export function TaskItemStatsRow({ : ""; return ( - + {updatedAt && ( {formatRelativeTime(updatedAt)} )} - {prInfo && #{prInfo.number}} + + {prInfo && #{prInfo.number}} {queuedCount ? ( void; - repositories?: string[]; + /** + * The task's repository as a stable slug (or name). A multi-repository task + * carries its primary repository here; enumerating the rest is deferred, + * since `TaskSwitcherItem.repositories` is not populated from live data. + */ + repositoryPath?: string; + /** + * Whether this row should name its repository. False when the list is grouped + * by repository, where the section header already says it. + */ + showRepository?: boolean; prInfo?: { number: number; state: string; aggregateState?: string }; /** Number of prompts currently en-queued for this task (mail badge). */ queuedCount?: number; @@ -341,7 +351,8 @@ function TaskItemContent({ primarySessionId, isArchived, isPinned, - repositories, + repositoryPath, + showRepository, updatedAt, lastActivityAt, showActivityTime, @@ -362,7 +373,8 @@ function TaskItemContent({ primarySessionId?: string | null; isArchived?: boolean; isPinned?: boolean; - repositories?: string[]; + repositoryPath?: string; + showRepository: boolean; updatedAt?: string; lastActivityAt?: string; showActivityTime?: boolean; @@ -402,11 +414,6 @@ function TaskItemContent({ )} - {repositories && repositories.length > 1 && ( - - {repositories.join(" ยท ")} - - )} {taskId && ( ({ + SessionMobileLayout: ({ repositoryLabel }: { repositoryLabel?: string | null }) => ( +
+ ), + SessionTabletLayout: () =>
, +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: true, usesDesktopWorkbench: false }), +})); + +vi.mock("./task-launch-error-context", () => ({ + useTaskLaunchErrorContext: () => ({ error: null, repositories: [] }), +})); + +describe("TaskLayout repository label", () => { + it("hands the repository label to the phone layout", () => { + render(); + + expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe( + "kdlbs/kandev", + ); + }); + + it("passes nothing along for a task with no repository", () => { + render(); + + expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe(""); + }); +}); diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index afcb5f0015..75616fabb4 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -29,6 +29,8 @@ type TaskLayoutProps = { initialTerminals?: Terminal[]; defaultLayouts?: Record; taskTitle?: string; + /** `owner/repo` (or the repository name) of the task's primary repository. */ + repositoryLabel?: string | null; baseBranch?: string; worktreeBranch?: string | null; isRemoteExecutor?: boolean; @@ -51,6 +53,7 @@ export const TaskLayout = memo(function TaskLayout({ initialTerminals, defaultLayouts = {}, taskTitle, + repositoryLabel, baseBranch, worktreeBranch, isRemoteExecutor, @@ -94,6 +97,7 @@ export const TaskLayout = memo(function TaskLayout({ baseBranch={baseBranch} worktreeBranch={worktreeBranch} taskTitle={taskTitle} + repositoryLabel={repositoryLabel} isRemoteExecutor={isRemoteExecutor} remoteExecutorType={remoteExecutorType} remoteExecutorName={remoteExecutorName} diff --git a/apps/web/components/task/task-page-content-helpers.test.ts b/apps/web/components/task/task-page-content-helpers.test.ts index 5af5f4fb2b..ae1cb5c88a 100644 --- a/apps/web/components/task/task-page-content-helpers.test.ts +++ b/apps/web/components/task/task-page-content-helpers.test.ts @@ -3,6 +3,7 @@ import { taskId as toTaskId, workflowId as toWorkflowId, workspaceId as toWorkspaceId, + type Repository, type Task, } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; @@ -108,6 +109,75 @@ describe("resolveTaskProps", () => { expect(props.issueUrl).toBe("https://github.com/kdlbs/kandev/issues/1470"); expect(props.issueNumber).toBe(1470); }); + + it("labels the repository by its provider slug, not its local clone path", () => { + const props = resolveTaskProps( + { id: "task-1", title: "Any" } as unknown as Task, + { + id: "repo-1", + name: "kandev", + local_path: "/home/dev/src/kandev", + provider_owner: "kdlbs", + provider_name: "kandev", + } as unknown as Repository, + ); + + expect(props.repositoryLabel).toBe("kdlbs/kandev"); + }); + + it("falls back to the repository name when no provider owns it", () => { + const props = resolveTaskProps( + { id: "task-1", title: "Any" } as unknown as Task, + { + id: "repo-1", + name: "scratchpad", + local_path: "/home/dev/src/scratchpad", + } as unknown as Repository, + ); + + expect(props.repositoryLabel).toBe("scratchpad"); + }); + + it("has no repository label for a task with no repository", () => { + const props = resolveTaskProps({ id: "task-1", title: "Any" } as unknown as Task, null); + + expect(props.repositoryLabel).toBeNull(); + }); +}); + +describe("buildArchivedValue repository identity", () => { + // The archived row renders this value, so a local clone path here would put + // "/home/dev/src/kandev" in the sidebar and give archived tasks a different + // repository grouping key from every ordinary task. + it("carries the provider slug, not the local clone path", () => { + const value = buildArchivedValue( + { id: "task-1", title: "Any", archived_at: ARCHIVED_AT } as unknown as Task, + { + id: "repo-1", + name: "kandev", + local_path: "/home/dev/src/kandev", + provider_owner: "kdlbs", + provider_name: "kandev", + } as unknown as Repository, + ); + + expect(value.archivedTaskRepositoryLabel).toBe("kdlbs/kandev"); + }); + + it("leaves the label unset for a task that is not archived", () => { + const value = buildArchivedValue( + { id: "task-1", title: "Any" } as unknown as Task, + { + id: "repo-1", + name: "kandev", + local_path: "/home/dev/src/kandev", + provider_owner: "kdlbs", + provider_name: "kandev", + } as unknown as Repository, + ); + + expect(value.archivedTaskRepositoryLabel).toBeUndefined(); + }); }); describe("resolveTaskContentState", () => { diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index c825556d76..7d944c3b90 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -7,6 +7,7 @@ import { } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; import { issueFieldsFromMetadata } from "@/lib/metadata-utils"; +import { repositorySlug } from "@/lib/repository-slug"; type ACPDebugInfo = { sessionId: unknown; @@ -169,7 +170,7 @@ export function buildArchivedValue(task: Task | null, repository: Repository | n isArchived, archivedTaskId: isArchived ? task?.id : undefined, archivedTaskTitle: isArchived ? task?.title : undefined, - archivedTaskRepositoryPath: isArchived ? (repository?.local_path ?? undefined) : undefined, + archivedTaskRepositoryLabel: isArchived && repository ? repositorySlug(repository) : undefined, archivedTaskUpdatedAt: isArchived ? task?.updated_at : undefined, }; } @@ -238,6 +239,12 @@ export function resolveTaskProps(task: Task | null, repository: Repository | nul issueNumber: issue.issueNumber, repositoryPath: repository?.local_path ?? null, repositoryName: repository?.name ?? null, + /** + * What the top bar shows so a user can tell which project an open task + * belongs to: the same `owner/repo` identity the sidebar rows and the + * repository filter use, never the local clone path. + */ + repositoryLabel: repository ? repositorySlug(repository) : null, /** * Total number of repositories linked to the task. Used by the top-bar * breadcrumb to render a "+N" chip next to the primary repo name when diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index 348538a078..183aaa0278 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -111,6 +111,7 @@ function buildTaskTopBarProps(params: { taskId: taskProps.taskId, activeSessionId: params.effectiveSessionId, taskTitle: taskProps.taskTitle, + repositoryLabel: taskProps.repositoryLabel, showDebugOverlay, onToggleDebugOverlay, workflowSteps, @@ -150,6 +151,7 @@ function buildTaskLayoutProps(params: { defaultLayouts: params.defaultLayouts, initialLayout: params.initialLayout, taskTitle: taskProps.taskTitle, + repositoryLabel: taskProps.repositoryLabel, baseBranch: taskProps.baseBranch, worktreeBranch: params.merged.worktreeBranch, isRemoteExecutor: params.remote.isRemoteExecutor, diff --git a/apps/web/components/task/task-session-sidebar-archived-item.ts b/apps/web/components/task/task-session-sidebar-archived-item.ts index 1170efac96..bec8620f21 100644 --- a/apps/web/components/task/task-session-sidebar-archived-item.ts +++ b/apps/web/components/task/task-session-sidebar-archived-item.ts @@ -15,7 +15,7 @@ export function buildArchivedSidebarItem( workflowName: undefined, workflowStepId: undefined, workflowStepTitle: undefined, - repositoryPath: s.archivedTaskRepositoryPath, + repositoryPath: s.archivedTaskRepositoryLabel, diffStats: undefined, isRemoteExecutor: false, remoteExecutorType: undefined, diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index f4b5cae32f..7827b9fff4 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -22,6 +22,8 @@ export type TaskRowProps = { activeTaskId: string | null; selectedTaskId: string | null; showActivityTime?: boolean; + /** False when the list is grouped by repository and the header already names it. */ + showRepository?: boolean; onSelectTask: (taskId: string) => void; onEditTask?: (task: TaskSwitcherItem) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; @@ -93,6 +95,7 @@ type TaskRowItemProps = Pick< | "activeTaskId" | "selectedTaskId" | "showActivityTime" + | "showRepository" | "onSelectTask" | "selectedTaskIds" | "onToggleSelectTask" @@ -110,6 +113,7 @@ function TaskRowItem({ activeTaskId, selectedTaskId, showActivityTime, + showRepository, onSelectTask, selectedTaskIds, onToggleSelectTask, @@ -161,7 +165,8 @@ function TaskRowItem({ updatedAt={task.updatedAt} lastActivityAt={task.lastActivityAt} showActivityTime={showActivityTime} - repositories={task.repositories} + repositoryPath={task.repositoryPath} + showRepository={showRepository} prInfo={task.prInfo} queuedCount={task.queuedCount} wipQueue={task.wipQueue} diff --git a/apps/web/components/task/task-switcher.test.tsx b/apps/web/components/task/task-switcher.test.tsx index 6eca3792f9..4d5a9b7ef1 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -531,3 +531,46 @@ describe("TaskSwitcher โ€” external issue link menu", () => { expect(closeMenu).toHaveBeenCalledOnce(); }); }); + +describe("TaskSwitcher repository labels", () => { + const REPOSITORY_TEST_ID = "sidebar-task-repository"; + const REPOSITORY_SLUG = "kdlbs/kandev"; + + function renderGroupedBy( + groupKey: GroupedSidebarList["groupKey"], + groups: GroupedSidebarList["groups"], + ) { + return render( + + + , + ); + } + + const REPO_TASK = item("Task A", undefined, { repositoryPath: REPOSITORY_SLUG }); + + it("names each task's repository in a flat, ungrouped list", () => { + renderGroupedBy("none", [{ key: "__all__", label: "All", tasks: [REPO_TASK] }]); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(REPOSITORY_SLUG); + }); + + it("names each task's repository when grouped by a dimension other than repository", () => { + renderGroupedBy("workflowStep", [{ key: "step-1", label: "In progress", tasks: [REPO_TASK] }]); + + expect(screen.getByTestId(REPOSITORY_TEST_ID).textContent).toBe(REPOSITORY_SLUG); + }); + + it("leaves the repository off the rows when the group header already names it", () => { + renderGroupedBy("repository", [ + { key: REPOSITORY_SLUG, label: REPOSITORY_SLUG, tasks: [REPO_TASK] }, + ]); + + expect(screen.queryByTestId(REPOSITORY_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 64e6821309..4524fb3e28 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -14,6 +14,15 @@ export type { } from "./task-switcher-types"; export { dispatchSidebarRowClick } from "./task-switcher-click"; +/** + * Rows name their repository unless the list is already grouped by it, where + * the section header says it once for the whole group. A list whose grouping is + * unknown (a hand-built `grouped` without `groupKey`) shows the label. + */ +function shouldShowRowRepository(grouped: TaskSwitcherProps["grouped"]): boolean { + return grouped.groupKey !== "repository"; +} + function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps { return { workflows: props.workflows, @@ -21,6 +30,7 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps { activeTaskId: props.activeTaskId, selectedTaskId: props.selectedTaskId, showActivityTime: props.showActivityTime, + showRepository: shouldShowRowRepository(props.grouped), onSelectTask: props.onSelectTask, onEditTask: props.onEditTask, onRenameTask: props.onRenameTask, diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index 3f149210d5..8ac06094ed 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; import { TaskTopBar } from "./task-top-bar"; @@ -151,6 +151,35 @@ describe("TaskTopBar registered change requests", () => { }); }); +describe("TaskTopBar repository crumb", () => { + function repositoryCrumb(label: string) { + return within(screen.getByRole("navigation", { name: "breadcrumb" })).queryByText(label); + } + + it("names the task's repository so an open task says which project it belongs to", () => { + renderTopBar( + , + ); + + const crumb = repositoryCrumb("kdlbs/kandev"); + expect(crumb).toBeTruthy(); + // Orientation, not navigation: there is no repository route to land on. + expect(crumb!.closest("a")).toBeNull(); + // Truncation is what protects the title, so the full name lives in `title`. + expect(crumb!.getAttribute("title")).toBe("kdlbs/kandev"); + }); + + it("renders no repository crumb for a task with no repository", () => { + renderTopBar(); + + // Any static crumb, not just a slug-shaped one: a repository with no + // provider falls back to a bare name like "scratchpad", which a + // slash-matching query would happily miss. + const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" }); + expect(breadcrumb.querySelector("[title]")).toBeNull(); + }); +}); + describe("TaskTopBar archived task controls", () => { it("passes the task ID to the unarchive button", () => { renderTopBar(); diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index e52326a291..226ffd9ac4 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -5,7 +5,7 @@ import Link from "@/components/routing/app-link"; import { IconBug, IconCircleDot } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { PageTopbar } from "@/components/page-topbar"; +import { PageTopbar, type ParentCrumb } from "@/components/page-topbar"; import { useOfficeProject } from "@/hooks/use-office-workspace-data"; import { TaskTopBarTitle } from "@/components/task/task-top-bar-title"; import { EditorsMenu } from "@/components/task/editors-menu"; @@ -31,6 +31,8 @@ type TaskTopBarProps = { taskId?: string | null; activeSessionId?: string | null; taskTitle?: string; + /** `owner/repo` (or the repository name) of the task's primary repository. */ + repositoryLabel?: string | null; showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; workflowSteps?: WorkflowStepperStep[]; @@ -51,6 +53,7 @@ const TaskTopBar = memo(function TaskTopBar({ taskId, activeSessionId, taskTitle, + repositoryLabel, showDebugOverlay, onToggleDebugOverlay, workflowSteps, @@ -72,6 +75,7 @@ const TaskTopBar = memo(function TaskTopBar({ const project = useOfficeProject(projectId); const showExecutorSettings = !isArchived && shouldShowExecutorEnvironmentControls(remoteExecutorType); + const parents = buildTaskCrumbs(project, repositoryLabel); return ( } - parents={ - project ? [{ label: project.name, href: `/office/projects/${project.id}` }] : undefined - } + parents={parents} // This bar is desktop-only (the mobile session bar owns phones), so the // phone-only home crumb and status trigger have no surface here. homeAffordance="none" @@ -125,6 +127,25 @@ const TaskTopBar = memo(function TaskTopBar({ ); }); +/** + * Ancestry crumbs for the open task: its office project (when it has one) and + * then its repository, closest to the title. The repository crumb carries no + * `href` on purpose: it orients ("this task is in kdlbs/kandev") rather than + * navigating, since there is no repository route to land on. + * + * A multi-repository task names its primary repository only, which is the same + * one the rest of the page (branch pickers, Changes) treats as primary. + */ +function buildTaskCrumbs( + project: { id: string; name: string } | null | undefined, + repositoryLabel: string | null | undefined, +): ParentCrumb[] | undefined { + const crumbs: ParentCrumb[] = []; + if (project) crumbs.push({ label: project.name, href: `/office/projects/${project.id}` }); + if (repositoryLabel) crumbs.push({ label: repositoryLabel }); + return crumbs.length > 0 ? crumbs : undefined; +} + // IssueTrackerButtons picks the right ticket status button for a task whose // title already carries an external issue key. Jira and Linear use the same // TEAM-NUMBER identifier shape, so both `extract` calls would match diff --git a/apps/web/lib/sidebar/apply-view-labels.test.ts b/apps/web/lib/sidebar/apply-view-labels.test.ts index 4ddfed1bbb..5f71c432c3 100644 --- a/apps/web/lib/sidebar/apply-view-labels.test.ts +++ b/apps/web/lib/sidebar/apply-view-labels.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, it, expect } from "vitest"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; -import { applyGroup } from "./apply-view"; +import { applyGroup, applyView } from "./apply-view"; import { i18n } from "@/lib/i18n"; import type { TaskState } from "@/lib/types/http"; @@ -87,3 +87,31 @@ describe("applyGroup โ€” group labels are catalog-backed", () => { expect(groups[0].label).toMatch(/[^\p{ASCII}]/u); }); }); + +describe("applyGroup โ€” grouping dimension is reported back", () => { + // Rows read `groupKey` to decide whether naming their own repository would + // just repeat the section header, so the grouped list has to carry it. + it("reports the dimension it grouped by", () => { + expect(applyGroup(REPO_TASKS, "repository").groupKey).toBe("repository"); + expect(applyGroup(REPO_TASKS, "workflowStep").groupKey).toBe("workflowStep"); + }); + + it("reports an ungrouped flat list as such", () => { + expect(applyGroup(REPO_TASKS, "none").groupKey).toBe("none"); + }); + + // applyView post-processes the grouped list (pinning, subtask order); the + // dimension has to survive that trip, since the rows read it from there. + it("survives applyView's post-processing", () => { + const view = { + filters: [], + sort: { key: "createdAt", direction: "desc" }, + group: "repository", + collapsedGroups: [], + } as unknown as Parameters[1]; + + expect(applyView(REPO_TASKS, view, { pinnedTaskIds: ["a"], orderedTaskIds: [] }).groupKey).toBe( + "repository", + ); + }); +}); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index a69f81d377..c1179a2597 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -23,6 +23,13 @@ export type SidebarGroup = { export type GroupedSidebarList = { groups: SidebarGroup[]; subTasksByParentId: Map; + /** + * Which dimension produced `groups`. Rows read it to decide whether their own + * repository label is redundant: grouped by repository, the section header + * already names it. Optional so a hand-built list (tests, fixtures) keeps + * meaning "unknown grouping" and falls back to showing the label. + */ + groupKey?: GroupKey; }; export type SidebarTaskPrefs = { @@ -336,6 +343,7 @@ export function applyGroup( return { groups: [{ key: "__all__", label: t(ALL_GROUP_LABEL_KEY), tasks: rootTasks }], subTasksByParentId, + groupKey, }; } @@ -360,7 +368,7 @@ export function applyGroup( sortRepoGroups(groups); } if (groupKey === "state") sortStateGroups(groups); - return { groups, subTasksByParentId }; + return { groups, subTasksByParentId, groupKey }; } function mergeSingleRepoUnassigned(groups: SidebarGroup[]): void {