diff --git a/products/desktop/apps/code/snapshots.yml b/products/desktop/apps/code/snapshots.yml index 843bb26229af..d7f89121d060 100644 --- a/products/desktop/apps/code/snapshots.yml +++ b/products/desktop/apps/code/snapshots.yml @@ -668,6 +668,14 @@ snapshots: hash: v1.k4693efd2.e01a8a548c694761b9f2a575268f420f653ad7eb9d26c2e33dc245ab03415fcf.Z5Ut_gF62xXnxkDgBXxE3yk6UDIAEVX5Sw_rKBPsqps settings-personalizationsettings--synced-truncated--light: hash: v1.k4693efd2.3c9f0dfa759b8c174abd8754593f0db9fd1518ff5225cccdcf0c3e83d4cdbe77.BqdmyuIiYJuGmHCQiRVAaBMk-xgycYOgVfwuMqma4LY + sidebar-taskitem--in-repository-group--dark: + hash: v1.k4693efd2.601f072b55c719b74db2abf8e4aa7f268f7c2cf80a279590fe841198392b49ee.sL5RJdxs9Ur7fkB9J_SO2L9JXFEpW1l1fmmQpd_-hEA + sidebar-taskitem--in-repository-group--light: + hash: v1.k4693efd2.0ae532ca59c02e645355bb9f08af295fb0c2afcd2c8c3cc6acd178f276acb416.6sNqaOArOyd2Wns3UDl8uQyd4aiwAAncKTbYidsZ9yw + sidebar-taskitem--pinned--dark: + hash: v1.k4693efd2.fef3cdc0c8a5485cabbb7ac37830d119d8939e864e972ee7a9cbd5e8c1a99fa9.6r2emBachF9VHn2cz9a2ktly_0P6sGl1iKfoQt9Sxu4 + sidebar-taskitem--pinned--light: + hash: v1.k4693efd2.8e36c4705d8650d806cf7c933684299aca5d751d1dbffd58e836e20015115bd5.gkDV3puY-rvStAc8WBaLWOOakDzZDYv8B3wTukt4QbQ skill-buttons-skillbuttonactionmessage--add-analytics--dark: hash: v1.k4693efd2.6d4ce4fac8e23a50efbbfaf24c2e11f8981f6778af2bfb2ca559749d7bda0eca.OnypKY5jyJSbQUZtTVcUy-eXC2euU-xQ2wFBYJOsKwI skill-buttons-skillbuttonactionmessage--add-analytics--light: diff --git a/products/desktop/packages/core/src/sidebar/groupTasks.ts b/products/desktop/packages/core/src/sidebar/groupTasks.ts index af674467d147..bbfb9e4748bd 100644 --- a/products/desktop/packages/core/src/sidebar/groupTasks.ts +++ b/products/desktop/packages/core/src/sidebar/groupTasks.ts @@ -16,7 +16,15 @@ export interface GroupableTask { originProduct?: string; } +export interface GroupableFolder { + path: string; + remoteUrl: string | null; + /** Set only on linked git worktrees: root of the main checkout. */ + mainRepoPath?: string | null; +} + export const CUSTOM_IMAGES_GROUP_ID = "custom-images"; +export const CUSTOM_IMAGES_GROUP_NAME = "Custom images"; export interface TaskGroup { id: string; @@ -53,10 +61,9 @@ export function getRepositoryInfo( return null; } -export function folderGroupId(folder: { - path: string; - remoteUrl: string | null; -}): string { +export function folderGroupId( + folder: Pick, +): string { if (folder.remoteUrl) { return normalizeRepoKey(folder.remoteUrl).toLowerCase(); } @@ -70,13 +77,10 @@ export function folderGroupId(folder: { * so prefer a folder that is not a linked worktree (`mainRepoPath` is set only * on linked worktrees). */ -export function findGroupFolder< - F extends { - path: string; - remoteUrl: string | null; - mainRepoPath?: string | null; - }, ->(folders: F[], groupId: string): F | undefined { +export function findGroupFolder( + folders: F[], + groupId: string, +): F | undefined { const matches = folders.filter((f) => folderGroupId(f) === groupId); return matches.find((f) => !f.mainRepoPath) ?? matches[0]; } @@ -95,7 +99,7 @@ export function groupByRepository( ? CUSTOM_IMAGES_GROUP_ID : (repository?.fullPath ?? "other"); const groupName = isImageBuilder - ? "Custom images" + ? CUSTOM_IMAGES_GROUP_NAME : (repository?.name ?? "Other"); let group = groupMap.get(groupId); diff --git a/products/desktop/packages/core/src/sidebar/taskContext.test.ts b/products/desktop/packages/core/src/sidebar/taskContext.test.ts new file mode 100644 index 000000000000..c218da875cd3 --- /dev/null +++ b/products/desktop/packages/core/src/sidebar/taskContext.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + formatTaskContext, + type TaskContextFolder, + type TaskContextTask, +} from "./taskContext"; + +const CODE_REPO = { + fullPath: "posthog/code", + name: "code", + organization: "PostHog", +}; + +function task(overrides: Partial = {}): TaskContextTask { + return { + repository: CODE_REPO, + workspaceMode: "local", + branchName: null, + linkedBranch: null, + ...overrides, + }; +} + +const mainClone: TaskContextFolder = { + name: "PostHog Desktop", + path: "/repos/code", + remoteUrl: "posthog/code", + mainRepoPath: null, +}; + +describe("formatTaskContext", () => { + it.each<{ name: string; task: TaskContextTask; expected: string | null }>([ + { + name: "repository only when the task has no branch of its own", + task: task(), + expected: "code", + }, + { + name: "repository and linked branch", + task: task({ linkedBranch: "posthog-code/fix-login" }), + expected: "code · posthog-code/fix-login", + }, + { + name: "the worktree's checked-out branch when no branch is linked yet", + task: task({ workspaceMode: "worktree", branchName: "wt/parser" }), + expected: "code · wt/parser", + }, + { + name: "the linked branch in preference to the checked-out branch", + task: task({ + workspaceMode: "worktree", + branchName: "wt/parser", + linkedBranch: "posthog-code/parser", + }), + expected: "code · posthog-code/parser", + }, + { + name: "no branch for a local task sitting on the default branch", + task: task({ workspaceMode: "local", branchName: "main" }), + expected: "code", + }, + { + name: "repository only for a cloud task with no linked branch", + task: task({ workspaceMode: "cloud" }), + expected: "code", + }, + { + name: "the custom-images group name for image-builder tasks", + task: task({ repository: null, originProduct: "image_builder" }), + expected: "Custom images", + }, + { + name: "the branch alone when the task has no repository", + task: task({ repository: null, linkedBranch: "posthog-code/orphan" }), + expected: "posthog-code/orphan", + }, + { + name: "null when there is neither a repository nor a branch", + task: task({ repository: null }), + expected: null, + }, + ])("renders $name", ({ task: subject, expected }) => { + expect(formatTaskContext(subject)).toBe(expected); + }); + + it("labels the repository with the registered folder's name", () => { + expect(formatTaskContext(task(), [mainClone])).toBe("PostHog Desktop"); + }); + + it("labels a worktree task with its main checkout's folder name", () => { + const worktree: TaskContextFolder = { + name: "code-wt", + path: "/repos/code-wt", + remoteUrl: "posthog/code", + mainRepoPath: "/repos/code", + }; + + expect( + formatTaskContext( + task({ workspaceMode: "worktree", branchName: "wt/parser" }), + [worktree, mainClone], + ), + ).toBe("PostHog Desktop · wt/parser"); + }); + + it("falls back to the repository name when no folder is registered", () => { + const unrelated: TaskContextFolder = { + name: "posthog", + path: "/repos/posthog", + remoteUrl: "posthog/posthog", + mainRepoPath: null, + }; + + expect(formatTaskContext(task(), [unrelated])).toBe("code"); + }); +}); diff --git a/products/desktop/packages/core/src/sidebar/taskContext.ts b/products/desktop/packages/core/src/sidebar/taskContext.ts new file mode 100644 index 000000000000..f56488c7ca99 --- /dev/null +++ b/products/desktop/packages/core/src/sidebar/taskContext.ts @@ -0,0 +1,52 @@ +import { + CUSTOM_IMAGES_GROUP_NAME, + findGroupFolder, + type GroupableFolder, +} from "./groupTasks"; +import type { TaskData } from "./sidebarData.types"; + +export type TaskContextTask = Pick< + TaskData, + | "repository" + | "originProduct" + | "workspaceMode" + | "branchName" + | "linkedBranch" +>; + +export interface TaskContextFolder extends GroupableFolder { + name: string; +} + +function repositoryLabel( + task: TaskContextTask, + folders: TaskContextFolder[], +): string | null { + if (task.originProduct === "image_builder") return CUSTOM_IMAGES_GROUP_NAME; + const repository = task.repository; + if (!repository) return null; + // The registered folder's name is what the group header shows. No collision + // prefix here: pinned tasks are partitioned out before groups are built. + return findGroupFolder(folders, repository.fullPath)?.name ?? repository.name; +} + +function branchLabel(task: TaskContextTask): string | null { + // `linkedBranch` stays unset while a task sits on the repo's default branch, + // so rows never all repeat "· main". Worktrees fall back to their checkout. + if (task.linkedBranch) return task.linkedBranch; + return task.workspaceMode === "worktree" ? task.branchName : null; +} + +/** + * ` · ` line for a row rendered outside its repository + * group (the pinned section), where no group header supplies the context. + */ +export function formatTaskContext( + task: TaskContextTask, + folders: TaskContextFolder[] = [], +): string | null { + const repository = repositoryLabel(task, folders); + const branch = branchLabel(task); + if (repository && branch) return `${repository} · ${branch}`; + return repository ?? branch; +} diff --git a/products/desktop/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/products/desktop/packages/ui/src/features/sidebar/components/SidebarItem.tsx index e1be24a5fa9c..367f7aff0822 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/products/desktop/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -1,10 +1,17 @@ -import { Button, cn } from "@posthog/quill"; +import { + Button, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types"; import { OverflowTickerText, useOverflowTickerReveal, } from "@posthog/ui/primitives/OverflowTickerText"; -import type { ComponentPropsWithRef } from "react"; +import { type ComponentPropsWithRef, useCallback } from "react"; export const INDENT_SIZE = 8; @@ -36,6 +43,51 @@ interface SidebarItemProps disabled?: boolean; } +function SidebarItemLabel({ + label, + grow, + className, +}: { + label: React.ReactNode; + grow: boolean; + className?: string; +}) { + const canTooltip = typeof label === "string" || typeof label === "number"; + + const measureRef = useCallback((el: HTMLSpanElement | null) => { + if (!el) return; + const update = () => { + el.style.pointerEvents = el.scrollWidth > el.clientWidth ? "" : "none"; + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const span = ( + + {label} + + ); + + if (!canTooltip) return span; + + return ( + + + + + {label} + + + + ); +} + export function SidebarItem({ depth, icon, @@ -65,6 +117,8 @@ export function SidebarItem({ className={cn( "group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors", "disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)", + // Quill's Button pins a fixed single-line height that would clip the second line. + subtitle && "h-auto! min-h-7 py-1", isDimmed && "opacity-50", )} data-active={isActive || undefined} @@ -103,9 +157,11 @@ export function SidebarItem({ {endContent} {subtitle ? ( - - {subtitle} - + ) : null} diff --git a/products/desktop/packages/ui/src/features/sidebar/components/TaskListView.tsx b/products/desktop/packages/ui/src/features/sidebar/components/TaskListView.tsx index f21d0b11fc2a..b39cb9b5cae0 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/products/desktop/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -11,6 +11,7 @@ import type { TaskData, TaskGroup, } from "@posthog/core/sidebar/sidebarData.types"; +import { formatTaskContext } from "@posthog/core/sidebar/taskContext"; import { MenuLabel } from "@posthog/quill"; import { builderHog } from "@posthog/ui/assets/hedgehogs"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; @@ -61,6 +62,7 @@ function SectionLabel({ label }: { label: string }) { function TaskRow({ task, + subtitle, isActive, isSelected, hideHoverActions, @@ -76,6 +78,7 @@ function TaskRow({ depth = 0, }: { task: TaskData; + subtitle?: string; isActive: boolean; isSelected: boolean; hideHoverActions: boolean; @@ -105,6 +108,7 @@ function TaskRow({ depth={depth} taskId={task.id} label={task.title} + subtitle={subtitle} isActive={isActive} isSelected={isSelected} isArchiving={isArchiving} @@ -208,6 +212,9 @@ export function TaskListView({ ): string | undefined { + return ( + formatTaskContext({ + repository: null, + branchName: null, + linkedBranch: null, + ...task, + }) ?? undefined + ); +} + +interface Row { + taskId: string; + label: string; + subtitle?: string; + ageHours: number; + isPinned?: boolean; + workspaceMode?: "local" | "worktree" | "cloud"; + taskRunStatus?: "completed"; + prState?: "open"; +} + +const noop = () => {}; + +function TaskRows({ + rows, + sectionLabel, +}: { + rows: Row[]; + sectionLabel?: string; +}) { + // Resolved at render, not in `args`: the VR runner freezes the clock in a + // decorator (apps/code/.storybook/preview.tsx), but args are evaluated at + // import time against the real clock, which collapses every label to "now". + const now = Date.now(); + return ( +
+ {sectionLabel ? ( + {sectionLabel} + ) : null} + {rows.map(({ ageHours, ...row }) => ( + + ))} +
+ ); +} + +const meta: Meta = { + title: "Sidebar/TaskItem", + component: TaskRows, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const InRepositoryGroup: Story = { + args: { + sectionLabel: "code", + rows: [ + { + taskId: "t1", + label: "Add keyboard shortcuts to the command palette", + ageHours: 1, + workspaceMode: "cloud", + taskRunStatus: "completed", + }, + { + taskId: "t2", + label: "Support drag and drop between projects", + ageHours: 26, + workspaceMode: "worktree", + prState: "open", + }, + ], + }, +}; + +export const Pinned: Story = { + args: { + sectionLabel: "Pinned", + rows: [ + { + taskId: "p1", + label: "Fix flaky timeout in the worktree cleanup test", + subtitle: context({ + repository: POSTHOG_REPO, + workspaceMode: "worktree", + branchName: "fix/worktree-cleanup-flake", + }), + ageHours: 0, + isPinned: true, + workspaceMode: "worktree", + }, + { + taskId: "p2", + label: "Add a retry banner to the diff viewer", + subtitle: context({ + repository: CODE_REPO, + workspaceMode: "local", + branchName: "main", + linkedBranch: "posthog-code/diff-retry-banner", + }), + ageHours: 3, + isPinned: true, + prState: "open", + }, + { + taskId: "p3", + label: "Document the release checklist for the desktop app", + subtitle: context({ repository: CODE_REPO, workspaceMode: "cloud" }), + ageHours: 27, + isPinned: true, + workspaceMode: "cloud", + taskRunStatus: "completed", + }, + { + taskId: "p4", + label: "Try out the new onboarding copy", + subtitle: context({}), + ageHours: 30, + isPinned: true, + }, + ], + }, +}; diff --git a/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx b/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx new file mode 100644 index 000000000000..9965ceabeda2 --- /dev/null +++ b/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TaskItem } from "./TaskItem"; + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToPullRequestView: vi.fn(), +})); + +function renderTaskItem(props: { subtitle?: string } = {}) { + return render( + {}} + onContextMenu={() => {}} + {...props} + />, + ); +} + +describe("TaskItem", () => { + it("renders the context line under the title when given one", () => { + renderTaskItem({ subtitle: "code · posthog-code/fix-login" }); + + expect( + screen.getByText("code · posthog-code/fix-login"), + ).toBeInTheDocument(); + }); + + it("stays a single line when there is no context to show", () => { + renderTaskItem(); + + expect( + screen.getByText("Write runbook for 5xx errors"), + ).toBeInTheDocument(); + expect(screen.getByRole("button").textContent).toBe( + "Write runbook for 5xx errors", + ); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.tsx b/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.tsx index 35a0745d1add..ef5ddb9272fd 100644 --- a/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.tsx +++ b/products/desktop/packages/ui/src/features/sidebar/components/items/TaskItem.tsx @@ -33,6 +33,8 @@ interface TaskItemProps { depth?: number; taskId: string; label: string; + /** Second line under the title, e.g. the task's `repository · branch`. */ + subtitle?: string; isActive: boolean; isSelected?: boolean; /** Archive request in flight: show a spinner and suppress hover actions. */ @@ -106,6 +108,7 @@ export function TaskItem({ depth = 0, taskId, label, + subtitle, isActive, isSelected = false, isArchiving = false, @@ -208,6 +211,7 @@ export function TaskItem({ depth={depth} icon={icon} label={label} + subtitle={subtitle} isActive={isActive} isSelected={isSelected} isDimmed={isArchiving}