Skip to content

Commit 548a51d

Browse files
authored
Restore task action row (diff button) on channel task detail
The unified Bluebird chrome moved the task action row (branch selector, review-panel toggle, handoff, task actions) into ContentHeader, which is mounted only outside /website — so tasks opened inside a channel lost the diff button. Extract the row into TaskHeaderActions and mount it in WebsiteLayout's title bar on channel task details. Generated-By: PostHog Code Task-Id: be6411d8-6b0f-429a-ad62-11312e69f7ac
1 parent e72c5b8 commit 548a51d

4 files changed

Lines changed: 300 additions & 178 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Theme } from "@radix-ui/themes";
2+
import { render, screen } from "@testing-library/react";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
const { useParams, usePathname, useTasks } = vi.hoisted(() => ({
6+
useParams: vi.fn(),
7+
usePathname: vi.fn(),
8+
useTasks: vi.fn(),
9+
}));
10+
11+
vi.mock("@tanstack/react-router", () => ({
12+
Outlet: () => null,
13+
useNavigate: () => vi.fn(),
14+
useParams,
15+
useRouterState: ({
16+
select,
17+
}: {
18+
select: (s: { location: { pathname: string } }) => string;
19+
}) => select({ location: { pathname: usePathname() } }),
20+
}));
21+
22+
// The action row drags in the whole task-header dependency tree (sessions,
23+
// git, review); this test only cares that WebsiteLayout mounts it on a
24+
// channel task route.
25+
vi.mock(
26+
"@posthog/ui/features/task-detail/components/TaskHeaderActions",
27+
() => ({
28+
TaskHeaderActions: ({ task }: { task: { id: string } }) => (
29+
<div data-testid="task-header-actions">{task.id}</div>
30+
),
31+
}),
32+
);
33+
34+
vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ useTasks }));
35+
vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
36+
useChannels: () => ({
37+
channels: [{ id: "chan-1", name: "project-bluebird" }],
38+
}),
39+
}));
40+
vi.mock("@posthog/ui/features/canvas/hooks/useDashboards", () => ({
41+
useDashboard: () => ({ dashboard: undefined }),
42+
useDashboardMutations: () => ({}),
43+
}));
44+
vi.mock("@posthog/ui/features/canvas/stores/dashboardEditStore", () => ({
45+
useDashboardEditStore: (sel: (s: unknown) => unknown) =>
46+
sel({ setEditing: vi.fn() }),
47+
useIsDashboardEditing: () => false,
48+
}));
49+
vi.mock("@posthog/ui/features/canvas/stores/freeformChatStore", () => ({
50+
useFreeformChatStore: (sel: (s: unknown) => unknown) =>
51+
sel({ revert: vi.fn(), goToLatest: vi.fn() }),
52+
useFreeformThread: () => ({
53+
code: "",
54+
versions: [],
55+
currentVersionId: null,
56+
isSaving: false,
57+
}),
58+
}));
59+
vi.mock("@posthog/ui/features/canvas/components/NewCanvasMenu", () => ({
60+
NewCanvasMenu: () => null,
61+
}));
62+
vi.mock("@posthog/ui/features/canvas/freeform/CanvasFrameHost", () => ({
63+
CanvasFrameHost: () => null,
64+
}));
65+
66+
import { useHeaderStore } from "@posthog/ui/shell/headerStore";
67+
import { WebsiteLayout } from "./WebsiteLayout";
68+
69+
function renderLayout({
70+
pathname,
71+
params,
72+
tasks = [{ id: "task-1", title: "Fix the bug" }],
73+
}: {
74+
pathname: string;
75+
params: Record<string, string>;
76+
tasks?: { id: string; title: string }[];
77+
}) {
78+
usePathname.mockReturnValue(pathname);
79+
useParams.mockReturnValue(params);
80+
useTasks.mockReturnValue({ data: tasks });
81+
useHeaderStore.setState({ content: <span>crumb</span> });
82+
render(
83+
<Theme>
84+
<WebsiteLayout />
85+
</Theme>,
86+
);
87+
}
88+
89+
describe("WebsiteLayout task header actions", () => {
90+
it("renders the task action row on a channel task detail", () => {
91+
renderLayout({
92+
pathname: "/website/chan-1/tasks/task-1",
93+
params: { channelId: "chan-1", taskId: "task-1" },
94+
});
95+
expect(screen.getByTestId("task-header-actions")).toHaveTextContent(
96+
"task-1",
97+
);
98+
});
99+
100+
it.each([
101+
["channel home", "/website/chan-1", { channelId: "chan-1" }],
102+
["new task", "/website/chan-1/new", { channelId: "chan-1" }],
103+
])("does not render the action row on %s", (_label, pathname, params) => {
104+
renderLayout({ pathname, params });
105+
expect(screen.queryByTestId("task-header-actions")).not.toBeInTheDocument();
106+
});
107+
});

packages/ui/src/features/canvas/components/WebsiteLayout.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
useFreeformThread,
3333
} from "@posthog/ui/features/canvas/stores/freeformChatStore";
3434
import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink";
35+
import { TaskHeaderActions } from "@posthog/ui/features/task-detail/components/TaskHeaderActions";
36+
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
3537
import { toast } from "@posthog/ui/primitives/toast";
3638
import { track } from "@posthog/ui/shell/analytics";
3739
import { useHeaderStore } from "@posthog/ui/shell/headerStore";
@@ -299,8 +301,15 @@ export function WebsiteLayout() {
299301

300302
const channelId = params.channelId;
301303
const dashboardId = params.dashboardId;
304+
const taskId = params.taskId;
302305
const base = channelId ? `/website/${channelId}` : "/website";
303306

307+
// On a channel task detail this bar also carries the task's action row
308+
// (branch selector, review-panel toggle, handoff, task actions) — the same
309+
// row ContentHeader renders for /code tasks, which isn't mounted here.
310+
const { data: tasks } = useTasks();
311+
const channelTask = taskId ? tasks?.find((t) => t.id === taskId) : undefined;
312+
304313
const { channels } = useChannels();
305314
const channelName = channelId
306315
? (channels.find((c) => c.id === channelId)?.name ?? "Channel")
@@ -329,7 +338,14 @@ export function WebsiteLayout() {
329338
gap="2"
330339
className="h-10 shrink-0 border-gray-6 border-b px-3"
331340
>
332-
{headerContent}
341+
<Flex
342+
align="center"
343+
justify="between"
344+
className="h-full min-w-0 flex-1 overflow-hidden"
345+
>
346+
{headerContent}
347+
</Flex>
348+
{channelTask && <TaskHeaderActions task={channelTask} />}
333349
</Flex>
334350
)}
335351

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Cloud, Spinner } from "@phosphor-icons/react";
2+
import { Button as QuillButton } from "@posthog/quill";
3+
import type { Task } from "@posthog/shared/domain-types";
4+
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
5+
import { AutoresearchHeaderButton } from "@posthog/ui/features/autoresearch/AutoresearchHeaderButton";
6+
import { useDiffStatsToggle } from "@posthog/ui/features/code-review/hooks/useDiffStatsToggle";
7+
import {
8+
formatHotkey,
9+
SHORTCUTS,
10+
} from "@posthog/ui/features/command/keyboard-shortcuts";
11+
import { DiffStatsBadge } from "@posthog/ui/features/diff-stats/DiffStatsBadge";
12+
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
13+
import { BranchSelector } from "@posthog/ui/features/git-interaction/components/BranchSelector";
14+
import { CloudGitInteractionHeader } from "@posthog/ui/features/git-interaction/components/CloudGitInteractionHeader";
15+
import { TaskActionsMenu } from "@posthog/ui/features/git-interaction/components/TaskActionsMenu";
16+
import { HandoffConfirmDialog } from "@posthog/ui/features/sessions/components/HandoffConfirmDialog";
17+
import { useHandoffDialogStore } from "@posthog/ui/features/sessions/handoffDialogStore";
18+
import { useSessionCallbacks } from "@posthog/ui/features/sessions/hooks/useSessionCallbacks";
19+
import { useSessionForTask } from "@posthog/ui/features/sessions/useSession";
20+
import { SkillButtonsMenu } from "@posthog/ui/features/skill-buttons/components/SkillButtonsMenu";
21+
import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace";
22+
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
23+
import { Flex } from "@radix-ui/themes";
24+
import { useState } from "react";
25+
26+
const CLOUD_HANDOFF_FLAG = "phc-cloud-handoff";
27+
28+
function LocalHandoffButton({ taskId, task }: { taskId: string; task: Task }) {
29+
const session = useSessionForTask(taskId);
30+
const workspace = useWorkspace(taskId);
31+
const repoPath = workspace?.folderPath ?? null;
32+
const authStatus = useAuthStateValue((s) => s.status);
33+
const cloudHandoffEnabled =
34+
useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV;
35+
const { initiateHandoffToCloud } = useSessionCallbacks({
36+
taskId,
37+
task,
38+
session: session ?? undefined,
39+
repoPath,
40+
});
41+
42+
const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen);
43+
const direction = useHandoffDialogStore((s) => s.direction);
44+
const branchName = useHandoffDialogStore((s) => s.branchName);
45+
const openConfirm = useHandoffDialogStore((s) => s.openConfirm);
46+
const closeConfirm = useHandoffDialogStore((s) => s.closeConfirm);
47+
48+
const [isSubmitting, setIsSubmitting] = useState(false);
49+
const [error, setError] = useState<string | null>(null);
50+
51+
if (authStatus !== "authenticated") return null;
52+
if (!cloudHandoffEnabled) return null;
53+
54+
const handleConfirm = async () => {
55+
setError(null);
56+
setIsSubmitting(true);
57+
try {
58+
await initiateHandoffToCloud();
59+
} catch (err) {
60+
setError(err instanceof Error ? err.message : "Handoff failed");
61+
} finally {
62+
setIsSubmitting(false);
63+
}
64+
};
65+
66+
const inProgress = session?.handoffInProgress ?? false;
67+
68+
return (
69+
<>
70+
<div className="no-drag flex items-center">
71+
<QuillButton
72+
variant="outline"
73+
size="sm"
74+
disabled={inProgress}
75+
onClick={() =>
76+
openConfirm(taskId, "to-cloud", workspace?.branchName ?? null)
77+
}
78+
>
79+
{inProgress ? (
80+
<Spinner size={14} className="shrink-0 animate-spin" />
81+
) : (
82+
<Cloud size={14} weight="regular" className="shrink-0" />
83+
)}
84+
{inProgress ? "Transferring..." : "Continue in cloud"}
85+
</QuillButton>
86+
</div>
87+
{confirmOpen && direction === "to-cloud" && (
88+
<HandoffConfirmDialog
89+
open={confirmOpen}
90+
onOpenChange={(open) => {
91+
if (!open) {
92+
closeConfirm();
93+
setError(null);
94+
}
95+
}}
96+
direction="to-cloud"
97+
branchName={branchName}
98+
onConfirm={handleConfirm}
99+
isSubmitting={isSubmitting}
100+
error={error}
101+
/>
102+
)}
103+
</>
104+
);
105+
}
106+
107+
function TaskDiffStatsBadge({ task }: { task: Task }) {
108+
const { filesChanged, linesAdded, linesRemoved, isOpen, toggle } =
109+
useDiffStatsToggle(task, "split");
110+
return (
111+
<Tooltip
112+
content={isOpen ? "Close review panel" : "Open review panel"}
113+
shortcut={formatHotkey(SHORTCUTS.TOGGLE_REVIEW_PANEL)}
114+
side="bottom"
115+
>
116+
<DiffStatsBadge
117+
filesChanged={filesChanged}
118+
linesAdded={linesAdded}
119+
linesRemoved={linesRemoved}
120+
active={isOpen}
121+
onClick={toggle}
122+
/>
123+
</Tooltip>
124+
);
125+
}
126+
127+
// A task detail's header action row: skill buttons, autoresearch, branch
128+
// selector, review-panel toggle (diff stats), cloud/local handoff, and the
129+
// task actions menu. Shared by the code-space ContentHeader and the
130+
// channels-space title bar (WebsiteLayout), which renders its own header.
131+
export function TaskHeaderActions({ task }: { task: Task }) {
132+
const workspace = useWorkspace(task.id);
133+
const isCloudTask = workspace?.mode === "cloud";
134+
135+
return (
136+
<Flex
137+
align="center"
138+
justify="end"
139+
gap="1"
140+
pr="1"
141+
pl="1"
142+
className="h-full max-w-[50%] shrink-0 overflow-hidden"
143+
>
144+
<div className="no-drag">
145+
<SkillButtonsMenu taskId={task.id} />
146+
</div>
147+
<div className="no-drag">
148+
<AutoresearchHeaderButton taskId={task.id} />
149+
</div>
150+
{workspace && (workspace.branchName || workspace.baseBranch) && (
151+
<div className="no-drag flex h-full min-w-0 items-center">
152+
<BranchSelector
153+
repoPath={workspace.worktreePath ?? workspace.folderPath ?? null}
154+
currentBranch={workspace.branchName ?? workspace.baseBranch ?? null}
155+
taskId={task.id}
156+
/>
157+
</div>
158+
)}
159+
<TaskDiffStatsBadge task={task} />
160+
161+
{isCloudTask ? (
162+
<CloudGitInteractionHeader taskId={task.id} task={task} />
163+
) : (
164+
<LocalHandoffButton taskId={task.id} task={task} />
165+
)}
166+
<TaskActionsMenu taskId={task.id} isCloud={isCloudTask} />
167+
</Flex>
168+
);
169+
}

0 commit comments

Comments
 (0)