From 4f85a75e2d0011e83e1d0aa380c391cd834904b0 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 15:30:36 -0400 Subject: [PATCH 1/2] feat(autoresearch): improve experimental task switching Generated-By: PostHog Code Task-Id: 76febe11-f045-4047-bcdf-3a626a174ce8 --- .../components/ChannelBreadcrumb.test.tsx | 43 ++++++ .../canvas/components/ChannelBreadcrumb.tsx | 14 +- .../canvas/components/WebsiteLayout.tsx | 1 + .../panels/components/LeafNodeRenderer.tsx | 1 + .../panels/components/TabbedPanel.test.tsx | 95 ++++++++++++ .../panels/components/TabbedPanel.tsx | 52 +++++-- .../task-detail/components/TaskDetail.tsx | 13 +- .../task-detail/components/TaskLogsPanel.tsx | 2 +- .../src/router/routes/code/tasks/$taskId.tsx | 2 +- .../website/$channelId/tasks/$taskId.tsx | 1 - scripts/measure-task-switch.mjs | 139 ++++++++++++++++++ 11 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx create mode 100644 packages/ui/src/features/panels/components/TabbedPanel.test.tsx create mode 100644 scripts/measure-task-switch.mjs 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() {
= 0 + ? args[portArgIndex + 1] + : (process.env.POSTHOG_CODE_CDP_PORT ?? 9222), +); +const rounds = Number(roundsArgIndex >= 0 ? args[roundsArgIndex + 1] : 7); +const titles = args.filter( + (_, index) => + index !== portArgIndex && + index !== portArgIndex + 1 && + index !== roundsArgIndex && + index !== roundsArgIndex + 1, +); + +if (titles.length !== 2 || !Number.isInteger(rounds) || rounds < 1) { + console.error( + 'Usage: node scripts/measure-task-switch.mjs [--port 9222] [--rounds 7] "Task A" "Task B"', + ); + process.exit(1); +} + +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); +const page = browser.contexts().flatMap((context) => context.pages())[0]; + +if (!page) { + throw new Error(`No renderer page found on CDP port ${port}`); +} + +async function switchTask(title) { + return page.evaluate(async (taskTitle) => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + let current = element; + while (current) { + const style = getComputedStyle(current); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + current = current.parentElement; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const getActiveTaskId = () => + [...document.querySelectorAll("[data-task-detail-id]")] + .find(isVisible) + ?.getAttribute("data-task-detail-id"); + const button = [ + ...document.querySelectorAll('button[draggable="true"]'), + ].find( + (candidate) => + !candidate.closest("#tabbed-panel-tab-bar") && + [...candidate.querySelectorAll("span")].some( + (span) => + span.childElementCount === 0 && + span.textContent?.trim() === taskTitle, + ), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Task button not found: ${taskTitle}`); + } + + const previousTaskId = getActiveTaskId(); + const startedAt = performance.now(); + + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + observer.disconnect(); + reject(new Error(`Task detail did not switch: ${taskTitle}`)); + }, 15000); + const observer = new MutationObserver(() => { + const taskId = getActiveTaskId(); + if ( + !taskId || + taskId === previousTaskId || + button.getAttribute("data-active") !== "true" + ) { + return; + } + + observer.disconnect(); + window.clearTimeout(timeout); + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + observer.observe(document.body, { + attributeFilter: ["data-task-detail-id", "style"], + attributes: true, + childList: true, + subtree: true, + }); + button.click(); + }); + + return performance.now() - startedAt; + }, title); +} + +const firstTaskIsActive = await page.evaluate((title) => { + const button = [ + ...document.querySelectorAll('button[draggable="true"]'), + ].find((candidate) => candidate.textContent?.includes(title)); + return button?.getAttribute("data-active") === "true"; +}, titles[0]); +let currentTitleIndex = firstTaskIsActive ? 0 : 1; +currentTitleIndex = (currentTitleIndex + 1) % 2; +await switchTask(titles[currentTitleIndex]); +const samples = []; +const directionalSamples = new Map(titles.map((title) => [title, []])); +for (let index = 0; index < rounds; index++) { + currentTitleIndex = (currentTitleIndex + 1) % 2; + const destinationTitle = titles[currentTitleIndex]; + const duration = await switchTask(destinationTitle); + samples.push(duration); + directionalSamples.get(destinationTitle).push(duration); +} + +samples.sort((left, right) => left - right); +const median = samples[Math.floor(samples.length / 2)]; +const directions = Object.fromEntries( + [...directionalSamples].map(([destinationTitle, durations]) => { + durations.sort((left, right) => left - right); + return [ + destinationTitle, + { + median: durations[Math.floor(durations.length / 2)], + samples: durations, + }, + ]; + }), +); +console.log(JSON.stringify({ median, samples, directions })); +await browser.close(); From 8dcf5e26b4c657ea19e2a96e8feec6da3f7ff152 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Fri, 17 Jul 2026 15:36:11 -0400 Subject: [PATCH 2/2] chore(autoresearch): remove local benchmark script Generated-By: PostHog Code Task-Id: 76febe11-f045-4047-bcdf-3a626a174ce8 --- scripts/measure-task-switch.mjs | 139 -------------------------------- 1 file changed, 139 deletions(-) delete mode 100644 scripts/measure-task-switch.mjs diff --git a/scripts/measure-task-switch.mjs b/scripts/measure-task-switch.mjs deleted file mode 100644 index 05edd24488..0000000000 --- a/scripts/measure-task-switch.mjs +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node - -import { chromium } from "playwright"; - -const args = process.argv.slice(2); -const portArgIndex = args.indexOf("--port"); -const roundsArgIndex = args.indexOf("--rounds"); -const port = Number( - portArgIndex >= 0 - ? args[portArgIndex + 1] - : (process.env.POSTHOG_CODE_CDP_PORT ?? 9222), -); -const rounds = Number(roundsArgIndex >= 0 ? args[roundsArgIndex + 1] : 7); -const titles = args.filter( - (_, index) => - index !== portArgIndex && - index !== portArgIndex + 1 && - index !== roundsArgIndex && - index !== roundsArgIndex + 1, -); - -if (titles.length !== 2 || !Number.isInteger(rounds) || rounds < 1) { - console.error( - 'Usage: node scripts/measure-task-switch.mjs [--port 9222] [--rounds 7] "Task A" "Task B"', - ); - process.exit(1); -} - -const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); -const page = browser.contexts().flatMap((context) => context.pages())[0]; - -if (!page) { - throw new Error(`No renderer page found on CDP port ${port}`); -} - -async function switchTask(title) { - return page.evaluate(async (taskTitle) => { - const isVisible = (element) => { - if (!(element instanceof HTMLElement)) return false; - let current = element; - while (current) { - const style = getComputedStyle(current); - if (style.display === "none" || style.visibility === "hidden") { - return false; - } - current = current.parentElement; - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - const getActiveTaskId = () => - [...document.querySelectorAll("[data-task-detail-id]")] - .find(isVisible) - ?.getAttribute("data-task-detail-id"); - const button = [ - ...document.querySelectorAll('button[draggable="true"]'), - ].find( - (candidate) => - !candidate.closest("#tabbed-panel-tab-bar") && - [...candidate.querySelectorAll("span")].some( - (span) => - span.childElementCount === 0 && - span.textContent?.trim() === taskTitle, - ), - ); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Task button not found: ${taskTitle}`); - } - - const previousTaskId = getActiveTaskId(); - const startedAt = performance.now(); - - await new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - observer.disconnect(); - reject(new Error(`Task detail did not switch: ${taskTitle}`)); - }, 15000); - const observer = new MutationObserver(() => { - const taskId = getActiveTaskId(); - if ( - !taskId || - taskId === previousTaskId || - button.getAttribute("data-active") !== "true" - ) { - return; - } - - observer.disconnect(); - window.clearTimeout(timeout); - requestAnimationFrame(() => requestAnimationFrame(resolve)); - }); - observer.observe(document.body, { - attributeFilter: ["data-task-detail-id", "style"], - attributes: true, - childList: true, - subtree: true, - }); - button.click(); - }); - - return performance.now() - startedAt; - }, title); -} - -const firstTaskIsActive = await page.evaluate((title) => { - const button = [ - ...document.querySelectorAll('button[draggable="true"]'), - ].find((candidate) => candidate.textContent?.includes(title)); - return button?.getAttribute("data-active") === "true"; -}, titles[0]); -let currentTitleIndex = firstTaskIsActive ? 0 : 1; -currentTitleIndex = (currentTitleIndex + 1) % 2; -await switchTask(titles[currentTitleIndex]); -const samples = []; -const directionalSamples = new Map(titles.map((title) => [title, []])); -for (let index = 0; index < rounds; index++) { - currentTitleIndex = (currentTitleIndex + 1) % 2; - const destinationTitle = titles[currentTitleIndex]; - const duration = await switchTask(destinationTitle); - samples.push(duration); - directionalSamples.get(destinationTitle).push(duration); -} - -samples.sort((left, right) => left - right); -const median = samples[Math.floor(samples.length / 2)]; -const directions = Object.fromEntries( - [...directionalSamples].map(([destinationTitle, durations]) => { - durations.sort((left, right) => left - right); - return [ - destinationTitle, - { - median: durations[Math.floor(durations.length / 2)], - samples: durations, - }, - ]; - }), -); -console.log(JSON.stringify({ median, samples, directions })); -await browser.close();