Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<Theme>
<ChannelBreadcrumb
channelName="Team"
leafLabel="Task A"
editScopeKey="task-a"
onRename={onRename}
/>
</Theme>,
);

fireEvent.doubleClick(screen.getByText("Task A"));
expect(screen.getByRole("textbox")).toHaveValue("Task A");

rerender(
<Theme>
<ChannelBreadcrumb
channelName="Team"
leafLabel="Task B"
editScopeKey="task-b"
onRename={onRename}
/>
</Theme>,
);

expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByText("Task B")).toBeInTheDocument();
expect(onRename).not.toHaveBeenCalled();
});
});
14 changes: 10 additions & 4 deletions packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string | null>(null);
const editing = editingScope === currentEditScope;
const navigate = useNavigate();

const channelSegment = (
Expand Down Expand Up @@ -87,10 +91,10 @@ export function ChannelBreadcrumb({
<HeaderTitleEditor
initialTitle={leafLabel}
onSubmit={(next) => {
setEditing(false);
setEditingScope(null);
onRename(next);
}}
onCancel={() => setEditing(false)}
onCancel={() => setEditingScope(null)}
/>
) : (
<Tooltip>
Expand All @@ -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
}
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ function CanvasBreadcrumb({
className: "",
})}
leafLabel={name}
editScopeKey={dashboardId}
onRename={(next) => void renameDashboard(dashboardId, next)}
trailing={trailing}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const LeafNodeRenderer: React.FC<LeafNodeRendererProps> = ({
return (
<TabbedPanel
panelId={node.id}
mountScopeKey={taskId}
content={contentWithComponents}
onActiveTabChange={onActiveTabChange}
onCloseOtherTabs={closeOtherTabs}
Expand Down
95 changes: 95 additions & 0 deletions packages/ui/src/features/panels/components/TabbedPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { PanelContent } from "../panelTypes";

vi.mock("@dnd-kit/react", () => ({
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 }) => (
<button type="button" onClick={onSelect}>
{label}
</button>
),
}));

import { TabbedPanel } from "./TabbedPanel";

function content(activeTabId: string): PanelContent {
return {
id: "main",
activeTabId,
showTabs: false,
tabs: [
{
id: "logs",
label: "Logs",
data: { type: "logs" },
component: <div data-testid="logs-content" />,
},
{
id: "review",
label: "Review",
data: { type: "review" },
component: <div data-testid="review-content" />,
},
],
};
}

describe("TabbedPanel", () => {
it("retains visited tabs within a task and resets them for another task", () => {
const { rerender } = render(
<Theme>
<TabbedPanel
panelId="main"
mountScopeKey="task-a"
content={content("logs")}
/>
</Theme>,
);

expect(screen.getByTestId("logs-content")).toBeInTheDocument();
expect(screen.queryByTestId("review-content")).not.toBeInTheDocument();

rerender(
<Theme>
<TabbedPanel
panelId="main"
mountScopeKey="task-a"
content={content("review")}
/>
</Theme>,
);

expect(screen.getByTestId("logs-content")).toBeInTheDocument();
expect(screen.getByTestId("review-content")).toBeInTheDocument();

rerender(
<Theme>
<TabbedPanel
panelId="main"
mountScopeKey="task-b"
content={content("logs")}
/>
</Theme>,
);

expect(screen.getByTestId("logs-content")).toBeInTheDocument();
expect(screen.queryByTestId("review-content")).not.toBeInTheDocument();
});
});
52 changes: 42 additions & 10 deletions packages/ui/src/features/panels/components/TabbedPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const TabBarButton = forwardRef<HTMLButtonElement, TabBarButtonProps>(

interface TabbedPanelProps {
panelId: string;
mountScopeKey: string;
content: PanelContent;
onActiveTabChange?: (panelId: string, tabId: string) => void;
onCloseOtherTabs?: (panelId: string, tabId: string) => void;
Expand All @@ -72,6 +73,7 @@ interface TabbedPanelProps {

export const TabbedPanel: React.FC<TabbedPanelProps> = ({
panelId,
mountScopeKey,
content,
onActiveTabChange,
onCloseOtherTabs,
Expand All @@ -86,6 +88,27 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
emptyState,
}) => {
const hostClient = useHostTRPCClient();
const [mountedTabs, setMountedTabs] = useState<{
scopeKey: string;
tabIds: Set<string>;
}>(() => ({ 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();
Expand Down Expand Up @@ -236,16 +259,25 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
>
{content.tabs.length > 0 &&
content.tabs.some((t) => t.id === content.activeTabId) ? (
content.tabs.map((tab) => (
<div
key={tab.id}
style={
tab.id === content.activeTabId ? activeTabStyle : hiddenTabStyle
}
>
{tab.component}
</div>
))
content.tabs
.filter(
(tab) =>
tab.id === content.activeTabId ||
(mountedTabs.scopeKey === mountScopeKey &&
mountedTabs.tabIds.has(tab.id)),
)
.map((tab) => (
<div
key={tab.id}
style={
tab.id === content.activeTabId
? activeTabStyle
: hiddenTabStyle
}
>
{tab.component}
</div>
))
) : emptyState ? (
emptyState
) : (
Expand Down
13 changes: 8 additions & 5 deletions packages/ui/src/features/task-detail/components/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@ export function TaskDetail({
useBlurOnEscape();
useWorkspaceEvents(taskId);

const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const isEditingTitle = editingTaskId === taskId;
const { renameTask } = useRenameTask();

const handleTitleEditSubmit = useCallback(
async (newTitle: string) => {
setIsEditingTitle(false);
setEditingTaskId(null);

try {
await renameTask({
Expand All @@ -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.
Expand Down Expand Up @@ -157,6 +158,7 @@ export function TaskDetail({
</span>
}
leafLabel={task.title}
editScopeKey={taskId}
onRename={handleTitleEditSubmit}
trailing={trailing}
/>
Expand All @@ -179,7 +181,7 @@ export function TaskDetail({
<Text
truncate
className="no-drag min-w-0 font-medium text-[13px]"
onDoubleClick={() => setIsEditingTitle(true)}
onDoubleClick={() => setEditingTaskId(taskId)}
>
{task.title}
</Text>
Expand All @@ -197,6 +199,7 @@ export function TaskDetail({
isEditingTitle,
workspaceMode,
effectiveRepoPath,
taskId,
handleTitleEditSubmit,
handleTitleEditCancel,
],
Expand Down Expand Up @@ -265,7 +268,7 @@ export function TaskDetail({
);

return (
<Box height="100%" ref={containerRef}>
<Box data-task-detail-id={taskId} height="100%" ref={containerRef}>
<Flex height="100%">
<Box className={`min-w-0 flex-1 ${isExpanded ? "hidden" : ""}`}>
<PanelLayout taskId={taskId} task={task} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) {
<BackgroundWrapper>
<Flex direction="column" height="100%" width="100%">
<Box className="min-h-0 flex-1">
<ErrorBoundary name="SessionView">
<ErrorBoundary name="SessionView" resetKey={taskId}>
<SessionView
events={events}
taskId={taskId}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/router/routes/code/tasks/$taskId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,5 @@ function TaskDetailRoute() {
return <TaskDetailSkeleton />;
}

return <TaskDetail key={task.id} task={task} />;
return <TaskDetail task={task} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ function ChannelTaskDetailRoute() {
<div className="flex h-full min-w-0">
<div className="min-w-0 flex-1">
<TaskDetail
key={task.id}
task={task}
channelName={channelName ?? "Channel"}
channelId={channelId}
Expand Down
Loading