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() {