Skip to content
11 changes: 11 additions & 0 deletions packages/core/src/context-menu/context-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ describe("ContextMenuService.showTaskContextMenu", () => {
);
});

it("can hide Archive prior tasks for task lists without that action", async () => {
const menu = new FakeContextMenu();
makeService(menu).showTaskContextMenu({
...baseTask,
showArchivePrior: false,
});
await menu.shown;
expect(labels(menu.lastItems)).not.toContain("Archive prior tasks");
expect(labels(menu.lastItems)).toContain("Archive");
});

it("resolves to null when the menu is dismissed", async () => {
const menu = new FakeContextMenu();
const result = makeService(menu).showTaskContextMenu(baseTask);
Expand Down
33 changes: 19 additions & 14 deletions packages/core/src/context-menu/context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class ContextMenuService {
canStop,
isInCommandCenter,
hasEmptyCommandCenterCell,
showArchivePrior = true,
channels,
} = input;
const { apps, lastUsedAppId } = await this.getExternalAppsData();
Expand Down Expand Up @@ -162,7 +163,7 @@ export class ContextMenuService {
...(!isInCommandCenter
? [
this.separator(),
this.item(
this.item<TaskAction>(
"Add to Command Center",
{ type: "add-to-command-center" as const },
{ enabled: hasEmptyCommandCenterCell ?? true },
Expand All @@ -172,19 +173,23 @@ export class ContextMenuService {
...fileToItems,
this.separator(),
this.item("Archive", { type: "archive" }),
this.item(
"Archive prior tasks",
{ type: "archive-prior" },
{
confirm: {
title: "Archive Prior Tasks",
message: "Archive all tasks older than this one?",
detail:
"This will archive every task created before this one. You can unarchive them later.",
confirmLabel: "Archive",
},
},
),
...(showArchivePrior
? [
this.item<TaskAction>(
"Archive prior tasks",
{ type: "archive-prior" },
{
confirm: {
title: "Archive Prior Tasks",
message: "Archive all tasks older than this one?",
detail:
"This will archive every task created before this one. You can unarchive them later.",
confirmLabel: "Archive",
},
},
),
]
: []),
]);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/context-menu/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const taskContextMenuInput = z.object({
canStop: z.boolean().optional(),
isInCommandCenter: z.boolean().optional(),
hasEmptyCommandCenterCell: z.boolean().optional(),
showArchivePrior: z.boolean().optional(),
// Top-level desktop_file_system channels available as "File to…" targets.
// Omit (or pass empty) to hide the submenu entirely.
channels: z.array(z.object({ id: z.string(), name: z.string() })).optional(),
Expand Down
23 changes: 21 additions & 2 deletions packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ChannelItemModel } from "@posthog/core/canvas/channelItems";
import type { TaskRunStatus } from "@posthog/shared/domain-types";
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ChannelItemRow } from "./ChannelItemRow";

const actions = {
Expand Down Expand Up @@ -74,4 +74,23 @@ describe("ChannelItemRow", () => {
// The glyph is wrapped, not replaced — no spinner swapped in its place.
expect(running.querySelector("svg")).not.toBeNull();
});

it("opens the task context menu from the row", () => {
const onContextMenu = vi.fn();

render(
<Theme>
<ChannelItemRow
actions={actions}
isActive={false}
item={item()}
onContextMenu={onContextMenu}
/>
</Theme>,
);

fireEvent.contextMenu(screen.getByText("Investigate signup drop-off"));

expect(onContextMenu).toHaveBeenCalledOnce();
});
});
23 changes: 23 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatRelativeTimeShort } from "@posthog/shared";
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
import { InlineEditInput } from "@posthog/ui/features/sidebar/components/items/TaskItem";
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
import { NestedButton } from "@posthog/ui/primitives/NestedButton";
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
Expand Down Expand Up @@ -70,10 +71,18 @@ export function ChannelItemRow({
item,
isActive,
actions,
isEditing = false,
onContextMenu,
onEditSubmit,
onEditCancel,
}: {
item: ChannelItemModel;
isActive: boolean;
actions: ChannelItemActions;
isEditing?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
onEditSubmit?: (newTitle: string) => void;
onEditCancel?: () => void;
}) {
const icon = itemIcon(item);
const statusLabel = runStatusLabel(item.rawStatus);
Expand All @@ -86,6 +95,19 @@ export function ChannelItemRow({
icon
);

if (isEditing) {
return (
<InlineEditInput
depth={0}
icon={rowIcon}
label={item.title}
isActive={isActive}
onSubmit={(newTitle) => onEditSubmit?.(newTitle)}
onCancel={() => onEditCancel?.()}
/>
);
}

return (
<PreviewCard.Root>
<PreviewCard.Trigger
Expand All @@ -100,6 +122,7 @@ export function ChannelItemRow({
label={<span>{item.title}</span>}
isActive={isActive}
onClick={() => actions.open(item)}
onContextMenu={onContextMenu}
endContent={
<>
<span className={TIMESTAMP_CLASS}>
Expand Down
87 changes: 71 additions & 16 deletions packages/ui/src/features/canvas/components/ChannelSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,14 @@ import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBa
import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow";
import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab";
import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems";
import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore";
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
import { useTaskContextMenu } from "@posthog/ui/features/tasks/useTaskContextMenu";
import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations";
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge";
import { logger } from "@posthog/ui/shell/logger";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { type ReactNode, useMemo, useState } from "react";

Expand All @@ -52,6 +58,7 @@ const cnHeaderButton = (active: boolean) =>
cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground");

const RECENTS_CAP = 30;
const log = logger.scope("channel-sidebar");

function RecentSectionHeader({
searchOpen,
Expand Down Expand Up @@ -199,6 +206,18 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {

const { items, actions, me, isLoading, channelMissing } =
useChannelItems(channelId);
const { showContextMenu, editingTaskId, setEditingTaskId } =
useTaskContextMenu();
const { renameTask } = useRenameTask();
const commandCenterCells = useCommandCenterStore((state) => state.cells);
const assignTaskToCommandCenter = useCommandCenterStore(
(state) => state.assignTask,
);
const { data: allTasks = [] } = useTasks({ showAllUsers: true });
const allTaskIds = useMemo(
() => new Set(allTasks.map((task) => task.id)),
[allTasks],
);

const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -227,6 +246,56 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
[items, query, createdByFilter, statusFilter, me],
);

const taskRow = (item: (typeof items)[number]) => (
<ChannelItemRow
key={item.key}
item={item}
isActive={item.key === activeKey}
actions={actions}
isEditing={item.kind === "task" && editingTaskId === item.id}
onContextMenu={
item.kind === "task"
? (event) =>
void showContextMenu(item, event, {
isPinned: item.pinned,
isInCommandCenter: commandCenterCells.includes(item.id),
hasEmptyCommandCenterCell: commandCenterCells.some(
(taskId) => taskId == null || !allTaskIds.has(taskId),
),
showArchivePrior: false,
onTogglePin: () => actions.togglePin(item),
onArchive: () => actions.archive(item),
onAddToCommandCenter: () => {
const cellIndex = commandCenterCells.findIndex(
(taskId) => taskId == null || !allTaskIds.has(taskId),
);
if (cellIndex === -1) return;
assignTaskToCommandCenter(cellIndex, item.id);
navigateToCommandCenter();
},
})
: undefined
}
onEditSubmit={
item.kind === "task"
? async (newTitle) => {
setEditingTaskId(null);
try {
await renameTask({
taskId: item.id,
currentTitle: item.title,
newTitle,
});
} catch (error) {
log.error("Failed to rename task", error);
}
}
: undefined
}
onEditCancel={() => setEditingTaskId(null)}
/>
);

const sectionRow = (
label: string,
icon: ReactNode,
Expand Down Expand Up @@ -313,14 +382,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
<>
<MenuLabel>Pinned</MenuLabel>
<div className="flex flex-col gap-px">
{pinnedItems.map((item) => (
<ChannelItemRow
key={item.key}
item={item}
isActive={item.key === activeKey}
actions={actions}
/>
))}
{pinnedItems.map(taskRow)}
</div>
</>
)}
Expand All @@ -343,14 +405,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
/>
{recentItems.length > 0 ? (
<div className="flex flex-col gap-px">
{recentItems.map((item) => (
<ChannelItemRow
key={item.key}
item={item}
isActive={item.key === activeKey}
actions={actions}
/>
))}
{recentItems.map(taskRow)}
</div>
) : (
<Empty className="border-0 py-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ describe("TaskArtifactsList", () => {
expect(state.reviewModes[task.id]).toBe("split");
});

it("lists every PR produced by the same run", () => {
mocks.runs = [
{
...run("run-1"),
output: {
pr_url: "https://github.com/acme/repo/pull/1",
pr_urls: [
"https://github.com/acme/repo/pull/1",
"https://github.com/acme/other-repo/pull/2",
],
},
} as TaskRun,
];

render(<TaskArtifactsList task={task} timeline={[]} />);

expect(screen.getByText("Pull request #1")).toBeTruthy();
expect(screen.getByText("Pull request #2")).toBeTruthy();
});

it("lists the files the agent uploaded, with their size", () => {
mocks.runs = [
run("run-1", { artifacts: [outputFile({ id: "a", size: 16861 })] }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EmptyMedia,
EmptyTitle,
} from "@posthog/quill";
import { readPrUrls } from "@posthog/shared";
import type {
Task,
TaskRun,
Expand Down Expand Up @@ -92,8 +93,7 @@ function buildRows(
// every copy would bury the current one under its own drafts.
const newestByName = new Map<string, { file: RunArtifact; runId: string }>();
for (const run of allRuns) {
const outputPr = run.output?.pr_url;
if (typeof outputPr === "string" && outputPr) {
for (const outputPr of readPrUrls(run.output)) {
addPr(outputPr, `output-pr:${outputPr}`);
}
for (const file of readRunOutputs(run)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/features/canvas/hooks/useChannelFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useMemo } from "react";
// Feeds are multiplayer: poll fast enough that a teammate's new task card and
// run-status flips feel live without a dedicated push channel.
const CHANNEL_FEED_POLL_INTERVAL_MS = 5_000;
export const channelFeedQueryRoot = ["channel-feed"] as const;

export function channelFeedQueryKey(channelId: string | undefined) {
return ["channel-feed", channelId ?? "none"] as const;
return [...channelFeedQueryRoot, channelId ?? "none"] as const;
}

/**
Expand Down
Loading
Loading