diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx
index bae485110f..e6f082a7a8 100644
--- a/packages/ui/src/features/command/CommandMenu.tsx
+++ b/packages/ui/src/features/command/CommandMenu.tsx
@@ -340,7 +340,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
? [
{
id: "open-review-panel",
- label: "Open review panel",
+ label: "Open diff view",
icon: (
),
diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts
index ddc82d1172..55ad5d9458 100644
--- a/packages/ui/src/features/command/keyboard-shortcuts.ts
+++ b/packages/ui/src/features/command/keyboard-shortcuts.ts
@@ -194,7 +194,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
{
id: "toggle-review-panel",
keys: SHORTCUTS.TOGGLE_REVIEW_PANEL,
- description: "Toggle review panel",
+ description: "Toggle diff view",
category: "navigation",
},
{
diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
index 7da0b54eb8..50c42f887c 100644
--- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
+++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
@@ -1,6 +1,12 @@
import { Cloud as CloudIcon } from "@phosphor-icons/react";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@posthog/quill";
import type { Task } from "@posthog/shared/domain-types";
-import { Flex, Text } from "@radix-ui/themes";
import type React from "react";
import { useMemo } from "react";
import { useHostCapabilities } from "../../../shell/useHostCapabilities";
@@ -56,23 +62,29 @@ export const LeafNodeRenderer: React.FC = ({
const activeTabId = tabs.some((t) => t.id === node.content.activeTabId)
? node.content.activeTabId
: (tabs[0]?.id ?? node.content.activeTabId);
+ const hiddenTabIds = useMemo(() => {
+ const visibleTabIds = new Set(tabs.map((tab) => tab.id));
+ const hiddenIds: string[] = [];
+ for (const tab of node.content.tabs) {
+ if (!visibleTabIds.has(tab.id)) hiddenIds.push(tab.id);
+ }
+ return hiddenIds;
+ }, [node.content.tabs, tabs]);
const cloudEmptyState = useMemo(
() =>
isCloud ? (
-
-
-
-
- Cloud runs are read-only
-
-
-
+
+
+
+
+
+ Cloud runs are read-only
+
+ Local workspace tools are unavailable for this run.
+
+
+
) : undefined,
[isCloud],
);
@@ -95,8 +107,20 @@ export const LeafNodeRenderer: React.FC = ({
onPanelFocus={onPanelFocus}
draggingTabId={draggingTabId}
draggingTabPanelId={draggingTabPanelId}
+ allowPanelSplit={!isCloud}
onAddTerminal={hideTerminal ? undefined : () => onAddTerminal(node.id)}
- onSplitPanel={(direction) => onSplitPanel(node.id, direction)}
+ onSplitPanel={
+ isCloud ? undefined : (direction) => onSplitPanel(node.id, direction)
+ }
+ onClosePanel={
+ tabs.length === 0 && hiddenTabIds.length > 0
+ ? () => {
+ for (const tabId of hiddenTabIds) {
+ closeTab(taskId, node.id, tabId);
+ }
+ }
+ : undefined
+ }
emptyState={cloudEmptyState}
/>
);
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
index 9ba602d936..33ad0313a2 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
@@ -3,6 +3,10 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PanelContent } from "../panelTypes";
+const panelDropZonesSpy = vi.hoisted(() =>
+ vi.fn((_props: { allowSplit?: boolean }) => null),
+);
+
vi.mock("@dnd-kit/react", () => ({
useDroppable: () => ({ ref: vi.fn() }),
}));
@@ -16,7 +20,7 @@ vi.mock("@posthog/host-router/react", () => ({
}));
vi.mock("./PanelDropZones", () => ({
- PanelDropZones: () => null,
+ PanelDropZones: panelDropZonesSpy,
}));
vi.mock("./PanelTab", () => ({
@@ -52,6 +56,26 @@ function content(activeTabId: string): PanelContent {
}
describe("TabbedPanel", () => {
+ it("disables split drop zones when panel splitting is unavailable", () => {
+ const droppableContent = { ...content("logs"), droppable: true };
+
+ render(
+
+
+ ,
+ );
+
+ expect(panelDropZonesSpy.mock.lastCall?.[0]).toEqual(
+ expect.objectContaining({ allowSplit: false }),
+ );
+ });
+
it("retains visited tabs within a task and resets them for another task", () => {
const { rerender } = render(
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index d34ad048ed..3c096ccb88 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -1,5 +1,5 @@
import { useDroppable } from "@dnd-kit/react";
-import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react";
+import { Plus, SquareSplitHorizontalIcon, X } from "@phosphor-icons/react";
import { useHostTRPCClient } from "@posthog/host-router/react";
import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones";
import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore";
@@ -65,8 +65,10 @@ interface TabbedPanelProps {
onPanelFocus?: (panelId: string) => void;
draggingTabId?: string | null;
draggingTabPanelId?: string | null;
+ allowPanelSplit?: boolean;
onAddTerminal?: () => void;
onSplitPanel?: (direction: SplitDirection) => void;
+ onClosePanel?: () => void;
rightContent?: React.ReactNode;
emptyState?: React.ReactNode;
}
@@ -82,8 +84,10 @@ export const TabbedPanel: React.FC = ({
onPanelFocus,
draggingTabId = null,
draggingTabPanelId = null,
+ allowPanelSplit = true,
onAddTerminal,
onSplitPanel,
+ onClosePanel,
rightContent,
emptyState,
}) => {
@@ -230,12 +234,21 @@ export const TabbedPanel: React.FC = ({
)}
- {(rightContent || (content.droppable && onSplitPanel)) && (
+ {(rightContent ||
+ onClosePanel ||
+ (content.droppable && onSplitPanel)) && (
{rightContent}
+ {onClosePanel && (
+
+
+
+
+
+ )}
{content.droppable && onSplitPanel && (
= ({
panelId={panelId}
isDragging={!!draggingTabId}
allowSplit={
- // Allow split if:
+ allowPanelSplit &&
+ // Within a splittable layout, allow the edge drop zones if:
// 1. Current panel has > 1 tab (same-panel split), OR
// 2. Dragging from a different panel (cross-panel split)
- content.tabs.length > 1 ||
- (draggingTabPanelId !== null && draggingTabPanelId !== panelId)
+ (content.tabs.length > 1 ||
+ (draggingTabPanelId !== null && draggingTabPanelId !== panelId))
}
/>
)}
diff --git a/packages/ui/src/features/task-detail/components/TaskHeaderActions.tsx b/packages/ui/src/features/task-detail/components/TaskHeaderActions.tsx
index d69a198bd9..b1b6eb4000 100644
--- a/packages/ui/src/features/task-detail/components/TaskHeaderActions.tsx
+++ b/packages/ui/src/features/task-detail/components/TaskHeaderActions.tsx
@@ -113,7 +113,7 @@ function TaskDiffStatsBadge({ task }: { task: Task }) {
useDiffStatsToggle(task, "split");
return (