diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx
new file mode 100644
index 0000000000..69067a0ba2
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx
@@ -0,0 +1,43 @@
+import { Theme } from "@radix-ui/themes";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@tanstack/react-router", () => ({
+ useNavigate: () => vi.fn(),
+}));
+
+import { ChannelBreadcrumb } from "./ChannelBreadcrumb";
+
+describe("ChannelBreadcrumb", () => {
+ it("closes title editing when the editable leaf changes", () => {
+ const onRename = vi.fn();
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ fireEvent.doubleClick(screen.getByText("Task A"));
+ expect(screen.getByRole("textbox")).toHaveValue("Task A");
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ expect(screen.getByText("Task B")).toBeInTheDocument();
+ expect(onRename).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
index 9bd0adaa1f..6644ac1ddb 100644
--- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
@@ -22,6 +22,7 @@ interface ChannelBreadcrumbProps {
leafIcon?: ReactNode;
/** The trailing (current page) segment label. */
leafLabel: string;
+ editScopeKey?: string;
/**
* When provided, the leaf becomes inline-editable: double-click to rename,
* Enter or blur to submit, Escape to cancel. Receives the trimmed new value.
@@ -40,10 +41,13 @@ export function ChannelBreadcrumb({
channelId,
leafIcon,
leafLabel,
+ editScopeKey,
onRename,
trailing,
}: ChannelBreadcrumbProps) {
- const [editing, setEditing] = useState(false);
+ const currentEditScope = editScopeKey ?? leafLabel;
+ const [editingScope, setEditingScope] = useState(null);
+ const editing = editingScope === currentEditScope;
const navigate = useNavigate();
const channelSegment = (
@@ -87,10 +91,10 @@ export function ChannelBreadcrumb({
{
- setEditing(false);
+ setEditingScope(null);
onRename(next);
}}
- onCancel={() => setEditing(false)}
+ onCancel={() => setEditingScope(null)}
/>
) : (
@@ -100,7 +104,9 @@ export function ChannelBreadcrumb({
truncate
className="no-drag min-w-0 whitespace-nowrap text-[13px]"
onDoubleClick={
- onRename ? () => setEditing(true) : undefined
+ onRename
+ ? () => setEditingScope(currentEditScope)
+ : undefined
}
/>
}
diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
index cb5fdad47e..9981a8e24c 100644
--- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
@@ -281,6 +281,7 @@ function CanvasBreadcrumb({
className: "",
})}
leafLabel={name}
+ editScopeKey={dashboardId}
onRename={(next) => void renameDashboard(dashboardId, next)}
trailing={trailing}
/>
diff --git a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
index dbd38de4c8..7da0b54eb8 100644
--- a/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
+++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx
@@ -86,6 +86,7 @@ export const LeafNodeRenderer: React.FC = ({
return (
({
+ useDroppable: () => ({ ref: vi.fn() }),
+}));
+
+vi.mock("@posthog/host-router/react", () => ({
+ useHostTRPCClient: () => ({
+ contextMenu: {
+ showSplitContextMenu: { mutate: vi.fn() },
+ },
+ }),
+}));
+
+vi.mock("./PanelDropZones", () => ({
+ PanelDropZones: () => null,
+}));
+
+vi.mock("./PanelTab", () => ({
+ PanelTab: ({ label, onSelect }: { label: string; onSelect: () => void }) => (
+
+ ),
+}));
+
+import { TabbedPanel } from "./TabbedPanel";
+
+function content(activeTabId: string): PanelContent {
+ return {
+ id: "main",
+ activeTabId,
+ showTabs: false,
+ tabs: [
+ {
+ id: "logs",
+ label: "Logs",
+ data: { type: "logs" },
+ component: ,
+ },
+ {
+ id: "review",
+ label: "Review",
+ data: { type: "review" },
+ component: ,
+ },
+ ],
+ };
+}
+
+describe("TabbedPanel", () => {
+ it("retains visited tabs within a task and resets them for another task", () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("logs-content")).toBeInTheDocument();
+ expect(screen.queryByTestId("review-content")).not.toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("logs-content")).toBeInTheDocument();
+ expect(screen.getByTestId("review-content")).toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("logs-content")).toBeInTheDocument();
+ expect(screen.queryByTestId("review-content")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index f36f35085f..d34ad048ed 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -56,6 +56,7 @@ const TabBarButton = forwardRef(
interface TabbedPanelProps {
panelId: string;
+ mountScopeKey: string;
content: PanelContent;
onActiveTabChange?: (panelId: string, tabId: string) => void;
onCloseOtherTabs?: (panelId: string, tabId: string) => void;
@@ -72,6 +73,7 @@ interface TabbedPanelProps {
export const TabbedPanel: React.FC = ({
panelId,
+ mountScopeKey,
content,
onActiveTabChange,
onCloseOtherTabs,
@@ -86,6 +88,27 @@ export const TabbedPanel: React.FC = ({
emptyState,
}) => {
const hostClient = useHostTRPCClient();
+ const [mountedTabs, setMountedTabs] = useState<{
+ scopeKey: string;
+ tabIds: Set;
+ }>(() => ({ scopeKey: mountScopeKey, tabIds: new Set() }));
+
+ useEffect(() => {
+ if (!content.activeTabId) return;
+ setMountedTabs((current) => {
+ if (current.scopeKey !== mountScopeKey) {
+ return {
+ scopeKey: mountScopeKey,
+ tabIds: new Set([content.activeTabId]),
+ };
+ }
+ if (current.tabIds.has(content.activeTabId)) return current;
+ return {
+ scopeKey: mountScopeKey,
+ tabIds: new Set(current.tabIds).add(content.activeTabId),
+ };
+ });
+ }, [content.activeTabId, mountScopeKey]);
const handleSplitClick = async () => {
const result = await hostClient.contextMenu.showSplitContextMenu.mutate();
@@ -236,16 +259,25 @@ export const TabbedPanel: React.FC = ({
>
{content.tabs.length > 0 &&
content.tabs.some((t) => t.id === content.activeTabId) ? (
- content.tabs.map((tab) => (
-
- {tab.component}
-
- ))
+ content.tabs
+ .filter(
+ (tab) =>
+ tab.id === content.activeTabId ||
+ (mountedTabs.scopeKey === mountScopeKey &&
+ mountedTabs.tabIds.has(tab.id)),
+ )
+ .map((tab) => (
+
+ {tab.component}
+
+ ))
) : emptyState ? (
emptyState
) : (
diff --git a/packages/ui/src/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx
index 4d62a700af..9ccaab8e8c 100644
--- a/packages/ui/src/features/task-detail/components/TaskDetail.tsx
+++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx
@@ -99,12 +99,13 @@ export function TaskDetail({
useBlurOnEscape();
useWorkspaceEvents(taskId);
- const [isEditingTitle, setIsEditingTitle] = useState(false);
+ const [editingTaskId, setEditingTaskId] = useState(null);
+ const isEditingTitle = editingTaskId === taskId;
const { renameTask } = useRenameTask();
const handleTitleEditSubmit = useCallback(
async (newTitle: string) => {
- setIsEditingTitle(false);
+ setEditingTaskId(null);
try {
await renameTask({
@@ -120,7 +121,7 @@ export function TaskDetail({
);
const handleTitleEditCancel = useCallback(() => {
- setIsEditingTitle(false);
+ setEditingTaskId(null);
}, []);
// Inside a channel the thread also gets a "copy link" share affordance.
// Memoized so the headerContent memo below isn't busted by unrelated renders.
@@ -157,6 +158,7 @@ export function TaskDetail({
}
leafLabel={task.title}
+ editScopeKey={taskId}
onRename={handleTitleEditSubmit}
trailing={trailing}
/>
@@ -179,7 +181,7 @@ export function TaskDetail({
setIsEditingTitle(true)}
+ onDoubleClick={() => setEditingTaskId(taskId)}
>
{task.title}
@@ -197,6 +199,7 @@ export function TaskDetail({
isEditingTitle,
workspaceMode,
effectiveRepoPath,
+ taskId,
handleTitleEditSubmit,
handleTitleEditCancel,
],
@@ -265,7 +268,7 @@ export function TaskDetail({
);
return (
-
+
diff --git a/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx
index 15b6f565f7..1cc7283356 100644
--- a/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx
+++ b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx
@@ -162,7 +162,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
-
+
;
}
- return ;
+ return ;
}
diff --git a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx
index b50fe78fe3..62ce110a5d 100644
--- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx
+++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx
@@ -63,7 +63,6 @@ function ChannelTaskDetailRoute() {