From 27ef35556a0c927fad0888ca6a8eff3de91551b8 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 15:54:39 -0700 Subject: [PATCH 01/20] Support inline renaming across sidebar items --- apps/app/.ladle/sidebar-rename-fixtures.ts | 108 +++++ .../components/project/ProjectActionsMenu.tsx | 41 +- .../sidebar/ProjectList.modes.test.tsx | 58 ++- .../src/components/sidebar/ProjectList.tsx | 211 +++++---- .../sidebar/ProjectRow.interactions.test.tsx | 159 ++++++- .../app/src/components/sidebar/ProjectRow.tsx | 273 +++++------ .../sidebar/SidebarChildToggleChevron.tsx | 4 + .../sidebar/SidebarHeaderControls.tsx | 4 + .../sidebar/SidebarInlineRename.test.tsx | 302 +++++++++++++ .../sidebar/SidebarInlineRename.tsx | 422 ++++++++++++++++++ .../sidebar/SidebarOverview.stories.tsx | 91 +++- .../components/sidebar/SidebarSectionRow.tsx | 32 +- .../src/components/sidebar/ThreadRow.test.tsx | 67 ++- apps/app/src/components/sidebar/ThreadRow.tsx | 38 +- .../sidebar/TopLevelSidebarSection.tsx | 32 +- .../thread/ThreadActionsMenu.test.tsx | 97 +++- .../components/thread/ThreadActionsMenu.tsx | 73 ++- .../ThreadActionsProvider.navigation.test.tsx | 6 +- .../thread/ThreadActionsProvider.test.tsx | 6 +- .../thread/ThreadActionsProvider.tsx | 42 +- .../ui/compact-long-press-menu.test.tsx | 21 + .../components/ui/compact-long-press-menu.tsx | 18 +- .../hooks/cache-owners/project-cache-owner.ts | 21 + .../app/src/hooks/mutations/host-mutations.ts | 6 +- .../src/hooks/mutations/project-mutations.ts | 7 +- .../mutations/thread-section-mutations.ts | 18 +- .../hooks/mutations/thread-state-mutations.ts | 2 + 27 files changed, 1848 insertions(+), 311 deletions(-) create mode 100644 apps/app/.ladle/sidebar-rename-fixtures.ts create mode 100644 apps/app/src/components/sidebar/SidebarInlineRename.test.tsx create mode 100644 apps/app/src/components/sidebar/SidebarInlineRename.tsx diff --git a/apps/app/.ladle/sidebar-rename-fixtures.ts b/apps/app/.ladle/sidebar-rename-fixtures.ts new file mode 100644 index 00000000000..aee5cc87b19 --- /dev/null +++ b/apps/app/.ladle/sidebar-rename-fixtures.ts @@ -0,0 +1,108 @@ +import type { Host } from "@bb/domain"; +import { + updateEnvironmentRequestSchema, + updateHostRequestSchema, + updateProjectRequestSchema, + updateThreadRequestSchema, + updateThreadSectionRequestSchema, + type SidebarBootstrapResponse, +} from "@bb/server-contract"; +import { makeThreadResponse } from "../src/test/fixtures/thread-responses"; +import { makeEnvironment } from "./story-fixtures"; + +export function installSidebarRenameStoryApi({ + navigation: initialNavigation, + hosts: initialHosts, + failNextSave, +}: { + navigation: SidebarBootstrapResponse; + hosts: Host[]; + failNextSave: () => boolean; +}) { + let navigation = structuredClone(initialNavigation); + const hosts = structuredClone(initialHosts); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === "/api/v1/sidebar-bootstrap") { + return Response.json(navigation); + } + if (request.method === "GET" && path === "/api/v1/hosts") { + return Response.json(hosts); + } + const projects = [...navigation.projects, navigation.personalProject]; + const threads = projects.flatMap((project) => project.threads); + const id = decodeURIComponent(path.split("/").at(-1) ?? ""); + const thread = threads.find((item) => item.id === id); + const project = projects.find((item) => item.id === id); + const host = hosts.find((item) => item.id === id); + const environmentThread = threads.find((item) => item.environmentId === id); + const isSection = path === "/api/v1/thread-sections"; + if ( + request.method !== "PATCH" || + !(thread || project || host || environmentThread || isSection) + ) { + return originalFetch(input, init); + } + await new Promise((resolve) => window.setTimeout(resolve, 700)); + if (failNextSave()) { + return Response.json( + { error: "Could not save the name. Try again.", code: "unavailable" }, + { status: 503 }, + ); + } + const body: unknown = await request.json(); + if (thread) { + const { title } = updateThreadRequestSchema.parse(body); + if (title !== undefined) thread.title = title; + return Response.json(makeThreadResponse({ ...thread, runtime: {} })); + } + if (project) { + const { name } = updateProjectRequestSchema.parse(body); + if (name !== undefined) project.name = name; + return Response.json(project); + } + if (host) { + host.name = updateHostRequestSchema.parse(body).name; + return Response.json(host); + } + if (environmentThread) { + const { name } = updateEnvironmentRequestSchema.parse(body); + for (const item of threads) { + if (item.environmentId === id && name !== undefined) + item.environmentName = name; + } + return Response.json( + makeEnvironment({ + id, + name: environmentThread.environmentName, + projectId: environmentThread.projectId, + branchName: environmentThread.environmentBranchName, + }), + ); + } + const section = updateThreadSectionRequestSchema.parse(body); + const name = section.name.trim(); + if ( + navigation.sections.some( + (item) => item.id !== section.id && item.name === name, + ) + ) { + return Response.json( + { error: "Section name already exists", code: "section_name_conflict" }, + { status: 409 }, + ); + } + navigation = { + ...navigation, + sections: navigation.sections.map((item) => + item.id === section.id ? { ...item, name } : item, + ), + }; + return Response.json({ id: section.id, name, updatedThreadCount: 0 }); + }; + return () => { + globalThis.fetch = originalFetch; + }; +} diff --git a/apps/app/src/components/project/ProjectActionsMenu.tsx b/apps/app/src/components/project/ProjectActionsMenu.tsx index a78cd841b11..ed59d364402 100644 --- a/apps/app/src/components/project/ProjectActionsMenu.tsx +++ b/apps/app/src/components/project/ProjectActionsMenu.tsx @@ -29,6 +29,8 @@ import { useProjectActions } from "./ProjectActionsProvider"; interface ProjectActionsMenuBaseProps { project: ProjectResponse; + onRename?: () => void; + onCloseAutoFocus?: (event: Event) => void; } interface ProjectActionsMenuProps extends ProjectActionsMenuBaseProps { @@ -36,6 +38,7 @@ interface ProjectActionsMenuProps extends ProjectActionsMenuBaseProps { } interface ProjectActionsContextMenuProps extends ProjectActionsMenuBaseProps { + disabled?: boolean; children: ReactNode; onOpenChange?: (open: boolean) => void; } @@ -53,6 +56,7 @@ function stopProjectActionsMenuClickPropagation(event: MouseEvent) { export function ProjectActionsMenuItems({ project, surface, + onRename, }: ProjectActionsMenuItemsProps) { const navigate = useNavigate(); const { hostId: pickerHostId } = usePathPickerHost(); @@ -77,7 +81,8 @@ export function ProjectActionsMenuItems({ surface={surface} icon="Edit" onSelect={() => { - requestRename(project); + if (onRename) onRename(); + else requestRename(project); }} > Rename @@ -111,6 +116,8 @@ export function ProjectActionsMenuItems({ export function ProjectActionsMenu({ project, triggerClassName, + onRename, + onCloseAutoFocus, }: ProjectActionsMenuProps) { return ( @@ -137,9 +144,14 @@ export function ProjectActionsMenu({ - + ); @@ -157,14 +169,23 @@ export function ProjectActionsContextMenu( function ProjectActionsCompactLongPressMenu({ children, + disabled, project, onOpenChange, + onRename, }: ProjectActionsContextMenuProps) { return ( } + disabled={disabled} + items={ + + } > {children} @@ -173,17 +194,27 @@ function ProjectActionsCompactLongPressMenu({ function ProjectActionsDesktopContextMenu({ children, + disabled, project, onOpenChange, + onRename, + onCloseAutoFocus, }: ProjectActionsContextMenuProps) { return ( - {children} + + {children} + - + ); diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index 48db4f6d65e..aed91a31d40 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -17,7 +17,7 @@ import { useAtomValue, } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ThreadListEntry } from "@bb/domain"; +import type { Host, ThreadListEntry } from "@bb/domain"; import { ActiveSidebarModeSections, MachineModeSections } from "./ProjectList"; import { buildMachineThreadGroups } from "@bb/client-core"; import { @@ -32,9 +32,19 @@ import { type SidebarSectionId, } from "./sidebarCollapsedAtoms"; import { useSidebarModeSectionOrder } from "./useSidebarModeSectionOrder"; -import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; +import { + makeHost, + makeThreadListEntry, +} from "@bb/test-helpers/domain-fixtures"; + +const mockUseHosts = vi.hoisted(() => + vi.fn<() => { data: Host[] }>(() => ({ data: [] })), +); +const mockRenameHost = vi.hoisted(() => vi.fn(async () => undefined)); -const mockUseHosts = vi.hoisted(() => vi.fn(() => ({ data: [] }))); +vi.mock("@/hooks/mutations/host-mutations", () => ({ + useRenameHost: () => ({ mutateAsync: mockRenameHost }), +})); vi.mock("@/hooks/queries/host-queries", () => ({ useHosts: mockUseHosts, @@ -186,6 +196,7 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) { afterEach(() => { cleanup(); vi.clearAllMocks(); + mockUseHosts.mockReturnValue({ data: [] }); window.localStorage.clear(); }); @@ -278,6 +289,47 @@ describe("sidebar organization mode sections", () => { expect(mockBuildMachineThreadGroups).toHaveBeenCalledWith([], []); }); + it("renames a resolved machine heading without expanding the group", async () => { + const store = createStore(); + const host = makeHost({ id: "host_rename", name: "Work laptop" }); + mockUseHosts.mockReturnValue({ data: [host] }); + store.set(sidebarMachineSectionOrderAtom, ["machine:host_rename"]); + store.set(sidebarCollapsedMachinesAtom, ["host_rename"]); + render( + + + , + ); + + fireEvent.doubleClick(screen.getByTitle("Work laptop")); + const input = await screen.findByRole("textbox", { name: "Machine name" }); + fireEvent.change(input, { target: { value: "Studio" } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(mockRenameHost).toHaveBeenCalledWith({ + hostId: host.id, + name: "Studio", + }), + ); + expect(store.get(sidebarCollapsedMachinesAtom)).toEqual([host.id]); + }); + + it("does not offer inline rename on fallback machine headings", () => { + const store = createStore(); + store.set(sidebarMachineSectionOrderAtom, ["machine:no-machine"]); + store.set(sidebarCollapsedMachinesAtom, ["no-machine"]); + render( + + + , + ); + fireEvent.doubleClick(screen.getByTitle("No machine")); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(mockRenameHost).not.toHaveBeenCalled(); + }); + it("surfaces shared runtime activity for a collapsed machine section", () => { const store = createStore(); store.set(sidebarMachineSectionOrderAtom, ["machine:no-machine"]); diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 19a3d48ecf9..12d52d22ecb 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, + type ComponentProps, type PointerEventHandler, type ReactNode, } from "react"; @@ -31,12 +32,17 @@ import { useReorderPinnedThread } from "@/hooks/mutations/thread-state-mutations import { useCreateThreadSection, useDeleteThreadSection, - useUpdateThreadSection, } from "@/hooks/mutations/thread-section-mutations"; import { isHostPathMissing, useHostPathExistence, } from "@/hooks/queries/host-path-queries"; +import { useRenameHost } from "@/hooks/mutations/host-mutations"; +import { + SidebarRenameProvider, + useSidebarRename, + useSidebarRenameState, +} from "./SidebarInlineRename"; import { useHosts, usePrimaryHost } from "@/hooks/queries/host-queries"; import { useDialogState } from "@/hooks/useDialogState"; import { usePromptDraftInputThreadIds } from "@/hooks/usePromptDraftStorage"; @@ -59,11 +65,7 @@ import { AppCommandShortcutHint, AppCommandShortcutPill, } from "@/components/commands/AppCommandShortcutHint"; -import { - ThreadSectionCreateDialog, - ThreadSectionRenameDialog, - type ThreadSectionRenameDialogTarget, -} from "@/components/dialogs/ThreadSectionCreateDialog"; +import { ThreadSectionCreateDialog } from "@/components/dialogs/ThreadSectionCreateDialog"; import { ConfirmDeleteDialog, ConfirmDeleteDialogContent, @@ -134,6 +136,7 @@ import { import { SidebarHeaderActionsProvider, SidebarHeaderControls, + SidebarSectionMenuItems, } from "./SidebarHeaderControls"; import { useAppCommandRunner, @@ -336,13 +339,25 @@ function normalizeCollapsedSidebarSectionIds( return normalized; } +function getThreadSortTitle( + thread: ThreadListEntry, + rename: ReturnType, +): string { + return getThreadDisplayTitle( + rename?.kind === "thread" && rename.id === thread.id + ? { ...thread, title: rename.initialName } + : thread, + ); +} + function compareByTitleAscending( left: ThreadListEntry, right: ThreadListEntry, resources?: ThreadTitleMentionResources, + rename: ReturnType = null, ): number { - const leftTitle = getThreadDisplayTitle(left); - const rightTitle = getThreadDisplayTitle(right); + const leftTitle = getThreadSortTitle(left, rename); + const rightTitle = getThreadSortTitle(right, rename); const titleDelta = ( resources ? resolveThreadTitleDisplayText(leftTitle, resources) : leftTitle ).localeCompare( @@ -360,17 +375,20 @@ function compareByTitleAscending( function getProjectThreadItemAlphaLabel( item: ProjectThreadItem, resources?: ThreadTitleMentionResources, + rename: ReturnType = null, ): string { let label: string; switch (item.kind) { case "thread": - label = getThreadDisplayTitle(item.node.thread); + label = getThreadSortTitle(item.node.thread, rename); break; case "environment": - label = getThreadDisplayTitle(item.group.nodes[0].thread); + label = getThreadSortTitle(item.group.nodes[0].thread, rename); break; case "section": - return item.group.name; + return rename?.kind === "section" && rename.id === item.group.id + ? rename.initialName + : item.group.name; } return resources ? resolveThreadTitleDisplayText(label, resources) : label; } @@ -379,11 +397,13 @@ function compareProjectThreadItemsByTitleAscending( left: ProjectThreadItem, right: ProjectThreadItem, resources?: ThreadTitleMentionResources, + rename: ReturnType = null, ): number { const labelDelta = getProjectThreadItemAlphaLabel( left, resources, - ).localeCompare(getProjectThreadItemAlphaLabel(right, resources)); + rename, + ).localeCompare(getProjectThreadItemAlphaLabel(right, resources, rename)); if (labelDelta !== 0) { return labelDelta; } @@ -413,6 +433,7 @@ export function getSidebarThreadComparator( sort: SidebarChronologicalSort, resources?: ThreadTitleMentionResources, direction: "default" | "ascending" | "descending" = "default", + rename: ReturnType = null, ): ThreadComparator { const normalizedSort = sort === "none" ? "updated" : sort; @@ -423,10 +444,10 @@ export function getSidebarThreadComparator( : -1; if (normalizedSort === "alpha") { const comparator: ThreadComparator = (left, right) => - multiplier * compareByTitleAscending(left, right, resources); + multiplier * compareByTitleAscending(left, right, resources, rename); comparator.compareItems = (left, right) => multiplier * - compareProjectThreadItemsByTitleAscending(left, right, resources); + compareProjectThreadItemsByTitleAscending(left, right, resources, rename); return comparator; } const base = @@ -992,7 +1013,6 @@ interface SectionModeSectionsProps extends BuiltInSectionRenderState { onCreateThreadInSection: (sectionId: string) => void; onProjectSelect?: () => void; onRemoveSection: (section: SidebarSectionDefinition) => void; - onRenameSection: (section: SidebarSectionDefinition) => void; onToggleEnvironmentCollapsed: ToggleCollapsedId; onToggleThreadCollapsed: ToggleCollapsedId; pinnedSection: BuiltInSidebarSectionOptions; @@ -1019,7 +1039,6 @@ function SectionModeSections({ onCreateThreadInSection, onProjectSelect, onRemoveSection, - onRenameSection, onToggleCollapsed, onToggleEnvironmentCollapsed, onToggleThreadCollapsed, @@ -1081,7 +1100,6 @@ function SectionModeSections({ collapsedEnvironmentIds={collapsedEnvironmentIds} onProjectSelect={onProjectSelect} onCreateThreadInSection={onCreateThreadInSection} - onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} @@ -1116,6 +1134,10 @@ interface MachineModeSectionsProps renderSectionDisplayOptions: ( sectionId: SidebarSectionId, label: string, + renameActions?: { + onRename: () => void; + onCloseAutoFocus: (event: Event) => void; + }, ) => ReactNode; isSectionDisplayOptionsOpen: (sectionId: SidebarSectionId) => boolean; selectedThreadId?: string; @@ -1124,6 +1146,40 @@ interface MachineModeSectionsProps threadsSection: Omit; } +function RenamableMachineSidebarSection({ + hostId, + renderActions, + ...props +}: ComponentProps & { + hostId: string; + renderActions: MachineModeSectionsProps["renderSectionDisplayOptions"]; +}) { + const { mutateAsync: renameHost } = useRenameHost(); + const rename = useSidebarRename({ + kind: "machine", + id: hostId, + ownerKey: `machine:${hostId}`, + name: props.label, + label: "Machine name", + maxLength: 100, + onSave: (name) => renameHost({ hostId, name }), + }); + return ( + { + if (rename.isEditing) event.preventDefault(); + }, + })} + /> + ); +} + export function MachineModeSections({ collapsedEnvironmentIds, collapsedSectionIds, @@ -1307,23 +1363,21 @@ export function MachineModeSections({ if (builtInSection !== undefined) return builtInSection; const section = machineSectionsById.get(sectionId); if (!section) return null; - return ( - toggleMachineCollapsed(section.key), - }} - consumeClickSuppression={consumeClickSuppression} - > + const sectionProps: ComponentProps = { + id: sectionId, + label: section.label, + disabled: reorderDisabled, + actions: renderSectionDisplayOptions(sectionId, section.label), + actionsOpen: isSectionDisplayOptionsOpen(sectionId), + actionsMobileAlways: true, + collapsedActivity: section.activity, + collapsedThreads: section.threadListState.threads, + collapseControl: { + isCollapsed: collapsedMachineKeys.has(section.key), + onToggleCollapsed: () => toggleMachineCollapsed(section.key), + }, + consumeClickSuppression, + children: ( - + ), + }; + return hosts?.some((host) => host.id === section.key) ? ( + + ) : ( + ); }} @@ -1397,10 +1461,6 @@ function ProjectListComponent({ isPending: isCreateThreadSectionPending, mutate: createThreadSectionMutate, } = useCreateThreadSection(); - const { - isPending: isUpdateThreadSectionPending, - mutate: updateThreadSectionMutate, - } = useUpdateThreadSection(); const { isPending: isDeleteThreadSectionPending, mutate: deleteThreadSectionMutate, @@ -1455,10 +1515,6 @@ function ProjectListComponent({ const [sectionCreateErrorMessage, setSectionCreateErrorMessage] = useState< string | null >(null); - const [sectionRenameErrorMessage, setSectionRenameErrorMessage] = useState< - string | null - >(null); - const sectionRenameDialog = useDialogState(); const sectionDeleteDialog = useDialogState(); const handleOpenCreateSectionDialog = useCallback(() => { setSectionCreateErrorMessage(null); @@ -1489,32 +1545,6 @@ function ProjectListComponent({ }, [createThreadSectionMutate], ); - const handleOpenRenameThreadSection = useCallback( - (section: SidebarSectionDefinition) => { - setSectionRenameErrorMessage(null); - sectionRenameDialog.onOpen({ id: section.id, name: section.name }); - }, - [sectionRenameDialog], - ); - const handleRenameThreadSection = useCallback( - (id: string, name: string) => { - setSectionRenameErrorMessage(null); - updateThreadSectionMutate( - { id, name }, - { - onSuccess: () => sectionRenameDialog.onClose(), - onError: (error) => - setSectionRenameErrorMessage( - getSectionMutationErrorMessage( - error, - "Failed to rename section.", - ), - ), - }, - ); - }, - [sectionRenameDialog, updateThreadSectionMutate], - ); const handleRemoveThreadSection = useCallback( (section: SidebarSectionDefinition) => { sectionDeleteDialog.onOpen(section); @@ -1540,15 +1570,6 @@ function ProjectListComponent({ }, [sectionDeleteDialog], ); - const handleRenameThreadSectionOpenChange = useCallback( - (open: boolean) => { - if (!open) { - setSectionRenameErrorMessage(null); - } - sectionRenameDialog.onOpenChange(open); - }, - [sectionRenameDialog], - ); const setCollapsedProjectIdList = useSetAtom(collapsedProjectIdsAtom); const [collapsedThreadIdList, setCollapsedThreadIdList] = useAtom( collapsedThreadIdsAtom, @@ -1571,6 +1592,10 @@ function ProjectListComponent({ const renderSectionDisplayOptions = ( sectionId: SidebarSectionId, label: string, + renameActions?: { + onRename: () => void; + onCloseAutoFocus: (event: Event) => void; + }, ) => { const menuId = `displayOptions:${sectionId}` as const; return ( @@ -1579,7 +1604,12 @@ function ProjectListComponent({ onNewThread={handleCreateProjectlessThread} open={openSidebarMenu === menuId} onOpenChange={(open) => setSidebarMenuOpen(menuId, open)} - /> + onCloseAutoFocus={renameActions?.onCloseAutoFocus} + > + {renameActions ? ( + + ) : null} + ); }; const isSectionDisplayOptionsOpen = (sectionId: SidebarSectionId) => @@ -1592,14 +1622,16 @@ function ProjectListComponent({ const setCollapsedSectionList = useSetAtom( sidebarCollapsedThreadSectionsAtom, ); + const activeRename = useSidebarRenameState(); const sidebarThreadComparator = useMemo( () => getSidebarThreadComparator( chronologicalSort, titleMentionResources, sortDirection, + activeRename, ), - [chronologicalSort, titleMentionResources, sortDirection], + [chronologicalSort, titleMentionResources, sortDirection, activeRename], ); const collapsedThreadIds = useMemo( () => new Set(collapsedThreadIdList), @@ -1801,15 +1833,6 @@ function ProjectListComponent({ onCreate={handleCreateThreadSection} /> ); - const sectionRenameDialogContent = ( - - ); const sectionDeleteDialogContent = ( {sectionCreateDialog} - {sectionRenameDialogContent} {sectionDeleteDialogContent} ); } -export const ProjectList = memo(ProjectListComponent); +export const ProjectList = memo(function ProjectList(props: ProjectListProps) { + return ( + + + + ); +}); diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index c24bb294612..03336ca4ede 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -7,6 +7,7 @@ import { screen, waitFor, } from "@testing-library/react"; +import { BbHttpError } from "@bb/sdk/browser"; import type { ThreadListEntry } from "@bb/domain"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; @@ -25,9 +26,19 @@ import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { makeProjectResponse } from "@/test/fixtures/projects"; const mockUpdateEnvironment = vi.hoisted(() => ({ - mutate: vi.fn(), - reset: vi.fn(), + mutateAsync: vi.fn(async () => undefined), })); +const mockUpdateProject = vi.hoisted(() => vi.fn(async () => undefined)); +const mockUpdateSection = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("@/hooks/mutations/project-mutations", () => ({ + useUpdateProject: () => ({ mutateAsync: mockUpdateProject }), +})); + +vi.mock("@/hooks/mutations/thread-section-mutations", () => ({ + useUpdateThreadSection: () => ({ mutateAsync: mockUpdateSection }), +})); + const mockArchiveEnvironmentThreads = vi.hoisted(() => ({ mutateAsync: vi.fn(async () => ({ ok: true, archivedThreadIds: [] })), })); @@ -49,8 +60,7 @@ vi.mock("@/hooks/mutations/environment-mutations", () => ({ useUpdateEnvironment: () => ({ error: null, isPending: false, - mutate: mockUpdateEnvironment.mutate, - reset: mockUpdateEnvironment.reset, + mutateAsync: mockUpdateEnvironment.mutateAsync, variables: undefined, }), })); @@ -194,6 +204,41 @@ describe("ProjectRow interactions", () => { expect(actions?.getAttribute("data-sidebar-hover-actions-open")).toBeNull(); }); + it("renames a project from its menu without collapsing its threads", async () => { + const { onToggleProjectCollapsed } = renderProjectRow(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Test project actions" }), + { button: 0 }, + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + const input = await screen.findByRole("textbox", { name: "Project name" }); + await waitFor(() => expect(document.activeElement).toBe(input)); + fireEvent.change(input, { target: { value: " Renamed project " } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(mockUpdateProject).toHaveBeenCalledWith({ + id: "proj_test", + name: "Renamed project", + }), + ); + expect(onToggleProjectCollapsed).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("retains a failed project rename for retry and cancels without a second write", async () => { + mockUpdateProject.mockRejectedValueOnce(new Error("Unavailable")); + renderProjectRow(); + fireEvent.doubleClick(screen.getByTitle("Test project")); + const input = await screen.findByRole("textbox", { name: "Project name" }); + fireEvent.change(input, { target: { value: "Recovered draft" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(await screen.findByRole("alert")).not.toBeNull(); + expect(input).toHaveProperty("value", "Recovered draft"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.queryByRole("textbox", { name: "Project name" })).toBeNull(); + expect(mockUpdateProject).toHaveBeenCalledOnce(); + }); + it("places the project disclosure after its label and keeps root threads flush", () => { const result = renderProjectRow(vi.fn(), { status: "ready", @@ -317,6 +362,74 @@ describe("ProjectRow interactions", () => { expect(screen.queryByLabelText("Plan mode active")).toBeNull(); }); + it("keeps a duplicate section name in place until corrected", async () => { + mockUpdateSection.mockRejectedValueOnce( + new BbHttpError({ + status: 409, + code: "section_name_conflict", + message: "Conflict", + body: null, + }), + ); + render( + + + + + 0} + sections={[{ id: "sec_rename", name: "Design" }]} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={[ + buildSidebarEntitySectionId("section", "sec_rename"), + ]} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + builtInSections={{ + collapsedSectionIds: new Set(), + onToggleCollapsed: vi.fn(), + pinned: { label: "Pinned", content: null }, + threads: { label: "Threads" }, + }} + /> + + + + , + ); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Design section actions" }), + { button: 0 }, + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + const input = await screen.findByRole("textbox", { name: "Section name" }); + fireEvent.change(input, { target: { value: "Existing section" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect( + await screen.findByText("A section with this name already exists."), + ).not.toBeNull(); + expect(input).toHaveProperty("value", "Existing section"); + fireEvent.change(input, { target: { value: "New section" } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(mockUpdateSection).toHaveBeenLastCalledWith({ + id: "sec_rename", + name: "New section", + }), + ); + await waitFor(() => + expect( + screen.queryByRole("textbox", { name: "Section name" }), + ).toBeNull(), + ); + }); + it("uses shared runtime precedence when a top-level section is collapsed", () => { const store = createStore(); const queryClient = new QueryClient(); @@ -587,13 +700,29 @@ describe("ProjectRow interactions", () => { ).toEqual(["Rename", "Archive"]); fireEvent.click(rename); - expect( - await screen.findByRole("dialog", { name: "Rename environment" }), - ).not.toBeNull(); - expect(screen.getByText("feat/menu-close")).not.toBeNull(); + const input = await screen.findByRole("textbox", { + name: "Environment name", + }); + expect(input.getAttribute("placeholder")).toBe("feat/menu-close"); + expect(input).toHaveProperty("value", "Feature workspace"); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(screen.queryByRole("dialog")).toBeNull(); await waitFor(() => { expect(screen.queryByRole("menuitem", { name: "Rename" })).toBeNull(); }); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(await screen.findByText("Name cannot be empty.")).not.toBeNull(); + expect(mockUpdateEnvironment.mutateAsync).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("button", { name: "Clear custom name" }), + ); + await waitFor(() => + expect(mockUpdateEnvironment.mutateAsync).toHaveBeenCalledWith({ + id: "env_test", + name: null, + }), + ); }, ); @@ -661,8 +790,16 @@ describe("ProjectRow interactions", () => { { button: 0 }, ); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); - expect( - await screen.findByRole("dialog", { name: "Rename environment" }), - ).not.toBeNull(); + const input = await screen.findByRole("textbox", { + name: "Environment name", + }); + fireEvent.change(input, { target: { value: " Release workspace " } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(mockUpdateEnvironment.mutateAsync).toHaveBeenCalledWith({ + id: "env_plain", + name: "Release workspace", + }), + ); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index ca4d3ac0289..2b6cb717709 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -46,7 +46,9 @@ import { useArchiveEnvironmentThreads, useUpdateEnvironment, } from "@/hooks/mutations/environment-mutations"; -import { useDialogState } from "@/hooks/useDialogState"; +import { useUpdateProject } from "@/hooks/mutations/project-mutations"; +import { useUpdateThreadSection } from "@/hooks/mutations/thread-section-mutations"; +import { useSidebarRename, useSidebarRenameState } from "./SidebarInlineRename"; import { Button } from "@bb/shared-ui/button"; import { DropdownMenu, @@ -65,10 +67,6 @@ import { ProjectActionsContextMenu, ProjectActionsMenuItems, } from "@/components/project/ProjectActionsMenu"; -import { - EnvironmentRenameDialog, - type EnvironmentRenameDialogTarget, -} from "@/components/dialogs/EnvironmentRenameDialog"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -90,7 +88,6 @@ import { type CollapsedChildActivity, } from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; -import { getMutationErrorMessage } from "@/lib/mutation-errors"; import { getSettingsProjectRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { appToast } from "@/components/ui/app-toast"; @@ -231,7 +228,6 @@ interface SectionThreadTreeProps { collapsedEnvironmentIds: Set; onProjectSelect?: () => void; onCreateThreadInSection?: (sectionId: string) => void; - onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -303,7 +299,6 @@ interface ThreadTreeItemRowProps { variant: ProjectThreadTreeVariant; onProjectSelect?: () => void; onCreateThreadInSection?: (sectionId: string) => void; - onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -324,7 +319,6 @@ interface SectionTreeItemRowProps { variant: ProjectThreadTreeVariant; onProjectSelect?: () => void; onCreateThreadInSection?: (sectionId: string) => void; - onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; @@ -389,7 +383,6 @@ interface EnvironmentThreadGroupHeaderProps { archiveThreadsPending: boolean; onArchiveThreads: () => void; onCreateNewThread: () => void; - onRenameEnvironment: () => void; onToggleCollapsed: (environmentId: string) => void; } @@ -399,6 +392,7 @@ interface EnvironmentThreadGroupHeaderActionsProps { onCreateNewThread: () => void; onRenameEnvironment: () => void; onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: (event: Event) => void; } interface UseArchiveEnvironmentThreadGroupActionArgs { @@ -413,23 +407,6 @@ interface UseArchiveEnvironmentThreadGroupActionResult { onArchiveThreads: () => void; } -interface UseEnvironmentThreadGroupRenameActionArgs { - environmentId: string; - representativeThread: ThreadListEntry; -} - -interface UseEnvironmentThreadGroupRenameActionResult { - onRenameDialogOpenChange: (open: boolean) => void; - onRenameEnvironment: () => void; - onSubmitRenameEnvironment: ( - environmentId: string, - name: string | null, - ) => void; - renameDialogTarget: EnvironmentRenameDialogTarget | null; - renameEnvironmentErrorMessage: string | null; - renameEnvironmentPending: boolean; -} - interface FormatArchivedEnvironmentThreadsToastTitleArgs { archivedThreadIds: readonly string[]; threads: readonly Pick[]; @@ -787,67 +764,13 @@ function useArchiveEnvironmentThreadGroupAction({ }; } -function useEnvironmentThreadGroupRenameAction({ - environmentId, - representativeThread, -}: UseEnvironmentThreadGroupRenameActionArgs): UseEnvironmentThreadGroupRenameActionResult { - const renameDialog = useDialogState(); - const updateEnvironment = useUpdateEnvironment(); - const { - error, - isPending, - mutate: updateEnvironmentMutate, - reset: resetUpdateEnvironment, - variables, - } = updateEnvironment; - const renameEnvironmentPending = isPending && variables?.id === environmentId; - const renameEnvironmentErrorMessage = - error && variables?.id === environmentId - ? getMutationErrorMessage({ - error, - fallbackMessage: "Failed to update environment.", - }) - : null; - const { onClose, onOpen, onOpenChange, target } = renameDialog; - - const onRenameEnvironment = useCallback(() => { - resetUpdateEnvironment(); - onOpen({ - ...(representativeThread.environmentBranchName !== null - ? { branchName: representativeThread.environmentBranchName } - : {}), - canClearName: representativeThread.environmentName !== null, - id: environmentId, - currentName: representativeThread.environmentName ?? "", - }); - }, [environmentId, onOpen, representativeThread, resetUpdateEnvironment]); - - const onSubmitRenameEnvironment = useCallback( - (targetEnvironmentId: string, name: string | null) => { - updateEnvironmentMutate( - { id: targetEnvironmentId, name }, - { onSuccess: onClose }, - ); - }, - [onClose, updateEnvironmentMutate], - ); - - return { - onRenameDialogOpenChange: onOpenChange, - onRenameEnvironment, - onSubmitRenameEnvironment, - renameDialogTarget: target, - renameEnvironmentErrorMessage, - renameEnvironmentPending, - }; -} - function EnvironmentThreadGroupHeaderActions({ archiveThreadsPending, onArchiveThreads, onCreateNewThread, onRenameEnvironment, onOpenChange, + onCloseAutoFocus, }: EnvironmentThreadGroupHeaderActionsProps) { return ( - + { onRenameEnvironment(); @@ -914,7 +842,6 @@ function EnvironmentThreadGroupHeader({ archiveThreadsPending, onArchiveThreads, onCreateNewThread, - onRenameEnvironment, onToggleCollapsed, }: EnvironmentThreadGroupHeaderProps) { const [isActionsOpen, setIsActionsOpen] = useState(false); @@ -933,6 +860,30 @@ function EnvironmentThreadGroupHeader({ }, providerLookup, ) ?? UNNAMED_ENVIRONMENT_LABEL; + const { mutateAsync: updateEnvironment } = useUpdateEnvironment(); + const rename = useSidebarRename({ + kind: "environment", + id: environmentId, + ownerKey: `environment:${environmentId}:${representativeThread.id}`, + name: representativeThread.environmentName ?? "", + label: "Environment name", + placeholder: + resolveEnvironmentDisplayName( + { + name: null, + branchName: representativeThread.environmentBranchName, + path: representativeThread.environmentPath, + environmentProviderId, + }, + providerLookup, + ) ?? UNNAMED_ENVIRONMENT_LABEL, + maxLength: 80, + onSave: (name) => updateEnvironment({ id: environmentId, name }), + onClear: + representativeThread.environmentName !== null + ? () => updateEnvironment({ id: environmentId, name: null }) + : undefined, + }); const iconName = getEnvironmentLabelIconName(providerLookup); const showRollupGlyph = isCollapsed && @@ -971,14 +922,24 @@ function EnvironmentThreadGroupHeader({ - - {displayName} - + {rename.editor ?? ( + { + event.preventDefault(); + event.stopPropagation(); + rename.startEditing(); + }} + > + {displayName} + + )} @@ -1015,7 +977,10 @@ function EnvironmentThreadGroupHeader({ archiveThreadsPending={archiveThreadsPending} onArchiveThreads={onArchiveThreads} onCreateNewThread={onCreateNewThread} - onRenameEnvironment={onRenameEnvironment} + onRenameEnvironment={rename.startEditing} + onCloseAutoFocus={(event) => { + if (rename.isEditing) event.preventDefault(); + }} onOpenChange={setIsActionsOpen} /> @@ -1027,6 +992,7 @@ function EnvironmentThreadGroupHeader({ return ( +
{content}
); @@ -1090,17 +1056,6 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ onProjectSelect?.(); createThreadInEnvironment(); }, [createThreadInEnvironment, onProjectSelect]); - const { - onRenameDialogOpenChange, - onRenameEnvironment, - onSubmitRenameEnvironment, - renameDialogTarget, - renameEnvironmentErrorMessage, - renameEnvironmentPending, - } = useEnvironmentThreadGroupRenameAction({ - environmentId, - representativeThread, - }); const nodeItems = useMemo( () => nodes.map((node) => ({ kind: "thread", node })), [nodes], @@ -1131,7 +1086,6 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ archiveThreadsPending={archiveThreadsPending} onArchiveThreads={onArchiveThreads} onCreateNewThread={handleCreateNewThread} - onRenameEnvironment={onRenameEnvironment} onToggleCollapsed={onToggleEnvironmentCollapsed} /> {!isCollapsed ? ( @@ -1168,13 +1122,6 @@ const EnvironmentThreadGroupRow = memo(function EnvironmentThreadGroupRow({ ) : null} - ); }); @@ -1189,7 +1136,6 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ variant, onProjectSelect, onCreateThreadInSection, - onRenameSection, onRemoveSection, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -1211,7 +1157,6 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ variant={variant} onProjectSelect={onProjectSelect} onCreateThreadInSection={onCreateThreadInSection} - onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} @@ -1382,7 +1327,6 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ variant, onProjectSelect, onCreateThreadInSection, - onRenameSection, onRemoveSection, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -1393,6 +1337,15 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ sortableRef, sortableStyle, }: SectionTreeItemRowProps) { + const { mutateAsync: updateSection } = useUpdateThreadSection(); + const rename = useSidebarRename({ + kind: "section", + id: section.id, + ownerKey: `section:${section.id}:${variant}:${depthOffset}`, + name: section.name, + label: "Section name", + onSave: (name) => updateSection({ id: section.id, name }), + }); const [isTopLevelActionsOpen, setIsTopLevelActionsOpen] = useState(false); const collapsedSections = useAtomValue(sidebarCollapsedThreadSectionsAtom); const setCollapsedSections = useSetAtom(sidebarCollapsedThreadSectionsAtom); @@ -1488,7 +1441,6 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ variant={variant} onProjectSelect={onProjectSelect} onCreateThreadInSection={onCreateThreadInSection} - onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} @@ -1521,11 +1473,12 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ : undefined } onOpenChange={setIsTopLevelActionsOpen} + onCloseAutoFocus={(event) => { + if (rename.isEditing) event.preventDefault(); + }} > onRenameSection(section) : undefined - } + onRename={rename.startEditing} onRemove={ onRemoveSection ? () => onRemoveSection(section) : undefined } @@ -1535,6 +1488,8 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ return ( onCreateThreadInSection(section.id) : undefined } - onRename={onRenameSection ? () => onRenameSection(section) : undefined} onRemove={onRemoveSection ? () => onRemoveSection(section) : undefined} onToggleCollapsed={handleToggleCollapsed} stickyLevel={stickyLevel} @@ -1806,10 +1762,35 @@ interface SectionThreadTreeItemsProps { onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; onCreateThreadInSection?: (sectionId: string) => void; - onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; } +function itemContainsRename( + item: ProjectThreadItem, + rename: NonNullable>, +): boolean { + switch (item.kind) { + case "thread": + return ( + (rename.kind === "thread" && item.node.thread.id === rename.id) || + item.node.children.some((child) => itemContainsRename(child, rename)) + ); + case "environment": + return ( + (rename.kind === "environment" && + item.group.environmentId === rename.id) || + item.group.nodes.some((node) => + itemContainsRename({ kind: "thread", node }, rename), + ) + ); + case "section": + return ( + (rename.kind === "section" && item.group.id === rename.id) || + item.group.items.some((child) => itemContainsRename(child, rename)) + ); + } +} + function useWindowedThreadItems({ items, collapsedThreadIds, @@ -1821,6 +1802,7 @@ function useWindowedThreadItems({ collapsedEnvironmentIds: Set; selectedThreadId?: string; }) { + const rename = useSidebarRenameState(); const collapsedSectionKeyList = useAtomValue( sidebarCollapsedThreadSectionsAtom, ); @@ -1850,14 +1832,18 @@ function useWindowedThreadItems({ [items, rowCountContext], ); const alwaysMountedKeys = useMemo(() => { - if (!selectedThreadId) { - return undefined; + const keys = new Set(); + for (const item of items) { + if ( + (selectedThreadId && + projectThreadItemContainsThread(item, selectedThreadId)) || + (rename && itemContainsRename(item, rename)) + ) { + keys.add(getSidebarItemKey(item)); + } } - const activeItem = items.find((item) => - projectThreadItemContainsThread(item, selectedThreadId), - ); - return activeItem ? new Set([getSidebarItemKey(activeItem)]) : undefined; - }, [items, selectedThreadId]); + return keys.size > 0 ? keys : undefined; + }, [items, selectedThreadId, rename]); return { itemKeys, estimateRows, getNavigationEntries, alwaysMountedKeys }; } @@ -1877,7 +1863,6 @@ function SectionThreadTreeItems({ onToggleThreadCollapsed, onToggleEnvironmentCollapsed, onCreateThreadInSection, - onRenameSection, onRemoveSection, }: SectionThreadTreeItemsProps) { const { itemKeys, estimateRows, getNavigationEntries, alwaysMountedKeys } = @@ -1924,7 +1909,6 @@ function SectionThreadTreeItems({ onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} onCreateThreadInSection={onCreateThreadInSection} - onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} sectionDnd={sectionDnd ?? undefined} /> @@ -2138,7 +2122,6 @@ export const ChronologicalSectionThreadSections = memo( collapsedEnvironmentIds, onProjectSelect, onCreateThreadInSection, - onRenameSection, onRemoveSection, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, @@ -2220,7 +2203,6 @@ export const ChronologicalSectionThreadSections = memo( onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} onCreateThreadInSection={onCreateThreadInSection} - onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} /> ); @@ -2375,6 +2357,17 @@ function ProjectRowComponent({ projectRowRef, projectRowStyle, }: ProjectRowProps) { + const { mutateAsync: updateProject } = useUpdateProject({ + showErrorToast: false, + }); + const rename = useSidebarRename({ + kind: "project", + id: project.id, + ownerKey: `project:${project.id}`, + name: project.name, + label: "Project name", + onSave: (name) => updateProject({ id: project.id, name }), + }); const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false); const [isContextActionsOpen, setIsContextActionsOpen] = useState(false); const isActionsOpen = isDropdownActionsOpen || isContextActionsOpen; @@ -2420,14 +2413,26 @@ function ProjectRowComponent({ showNewThread={!isLocalPathInvalid} onNewThread={onCreateProjectThread ? handleCreateThread : undefined} onOpenChange={setIsDropdownActionsOpen} + onCloseAutoFocus={(event) => { + if (rename.isEditing) event.preventDefault(); + }} > - + ); return ( { + if (rename.isEditing) event.preventDefault(); + }} onOpenChange={setIsContextActionsOpen} >
diff --git a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx index bcee3a0b7e5..ee4422337d5 100644 --- a/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx +++ b/apps/app/src/components/sidebar/SidebarChildToggleChevron.tsx @@ -8,6 +8,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { SIDEBAR_CONTROL_STATE_CLASS } from "./sidebarRowClasses"; interface SidebarChildToggleChevronProps { + disabled?: boolean; isCollapsed: boolean; expandLabel: string; collapseLabel: string; @@ -17,6 +18,7 @@ interface SidebarChildToggleChevronProps { } export function SidebarChildToggleChevron({ + disabled = false, isCollapsed, expandLabel, collapseLabel, @@ -27,6 +29,8 @@ export function SidebarChildToggleChevron({ return ( + {rename.isEditing ? rename.editor : {name}} +
+ ); +} + +function SessionState() { + const state = useSidebarRenameState(); + return {state?.id ?? "none"}; +} + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function start(value = "New name") { + fireEvent.click(screen.getByRole("button", { name: "Rename first" })); + const input = screen.getByRole("textbox", { name: "first name" }); + fireEvent.change(input, { target: { value } }); + return input; +} + +describe("sidebar inline rename", () => { + it("selects the current name and restores row focus after Escape without saving", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("button", { name: "Rename first" })); + const input = screen.getByRole("textbox", { + name: "first name", + }) as HTMLInputElement; + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe("Original name".length); + fireEvent.change(input, { target: { value: "Discard me" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(onSave).not.toHaveBeenCalled(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Rename first" }), + ), + ); + }); + + it("saves a trimmed value once while Enter and blur overlap, and cannot cancel an in-flight save", async () => { + const pending = deferred(); + const onSave = vi.fn().mockReturnValue(pending.promise); + render(); + const input = start(" New name "); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.blur(input); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => + expect(onSave).toHaveBeenCalledExactlyOnceWith("New name"), + ); + expect(screen.getByRole("status", { name: "Saving name" })).not.toBeNull(); + expect(screen.getByRole("textbox").getAttribute("readonly")).toBe(""); + await act(async () => pending.resolve()); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("validates empty and overlong values and treats a trimmed unchanged name as a no-op", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + const input = start(" "); + fireEvent.keyDown(input, { key: "Enter" }); + expect(screen.getByRole("alert").textContent).toBe("Name cannot be empty."); + expect(input.getAttribute("aria-invalid")).toBe("true"); + fireEvent.change(input, { target: { value: "A name that is too long" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(screen.getByRole("alert").textContent).toBe( + "Name must be 20 characters or fewer.", + ); + fireEvent.change(input, { target: { value: " Original name " } }); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("does not submit composition Enter or blur within the editor; pointer Cancel wins", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + const input = start(); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + fireEvent.compositionEnd(input); + fireEvent.blur(input, { + relatedTarget: screen.getByRole("button", { name: "Save name" }), + }); + const cancel = screen.getByRole("button", { name: "Cancel rename" }); + expect(fireEvent.pointerDown(cancel)).toBe(false); + fireEvent.click(cancel, { detail: 1 }); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("retains a rejected draft and retries with the same value", async () => { + const onSave = vi + .fn() + .mockRejectedValueOnce(new Error("Network unavailable")) + .mockResolvedValueOnce(undefined); + render(); + fireEvent.keyDown(start(), { key: "Enter" }); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toBe( + "Could not save the name. Try again.", + ), + ); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe( + "New name", + ); + fireEvent.click(screen.getByRole("button", { name: "Retry saving name" })); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + expect(onSave.mock.calls).toEqual([["New name"], ["New name"]]); + }); + + it("uses the server section conflict and prevents retry for deleted entities", async () => { + const onSave = vi + .fn() + .mockRejectedValueOnce( + new BbHttpError({ + body: null, + code: "section_name_conflict", + message: "Conflict", + status: 409, + }), + ) + .mockRejectedValueOnce( + new BbHttpError({ + body: null, + code: null, + message: "Missing", + status: 404, + }), + ); + render(); + fireEvent.keyDown(start(), { key: "Enter" }); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toBe( + "A section with this name already exists.", + ), + ); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Another name" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toBe( + "This item no longer exists.", + ), + ); + expect( + screen + .getByRole("button", { name: "Retry saving name" }) + .hasAttribute("disabled"), + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Cancel rename" })); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("saves on departure without stealing focus after the response", async () => { + const pending = deferred(); + const onSave = vi.fn().mockReturnValue(pending.promise); + render( + <> + + + , + ); + start(); + const destination = screen.getByRole("button", { name: "Elsewhere" }); + act(() => destination.focus()); + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + await act(async () => pending.resolve()); + expect(document.activeElement).toBe(destination); + }); + + it("preserves a draft through external updates and displays the latest name after cancel", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const { rerender } = render(); + start("My draft"); + rerender(); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe( + "My draft", + ); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.getByText("Changed elsewhere")).not.toBeNull(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("keeps an invalid active draft when another row asks to rename, then saves before switching", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + + + + + , + ); + const input = start(" "); + fireEvent.click(screen.getByRole("button", { name: "Rename second" })); + await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull()); + expect(screen.queryByRole("textbox", { name: "second name" })).toBeNull(); + fireEvent.change(input, { target: { value: "Finish first" } }); + fireEvent.click(screen.getByRole("button", { name: "Rename second" })); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "second name" }), + ).not.toBeNull(), + ); + expect(onSave).toHaveBeenCalledExactlyOnceWith("Finish first"); + expect(screen.getByLabelText("Active rename").textContent).toBe("second"); + }); + + it("preserves the provider draft when its owning row unmounts and remounts", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const { rerender } = render( + + + , + ); + start("Keep my draft"); + rerender({null}); + rerender( + + + , + ); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe( + "Keep my draft", + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("clears environment names only through the explicit clear action", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const onClear = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.keyDown(start(" "), { key: "Enter" }); + expect(onClear).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Clear custom name" })); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + expect(onSave).not.toHaveBeenCalled(); + expect(onClear).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/app/src/components/sidebar/SidebarInlineRename.tsx b/apps/app/src/components/sidebar/SidebarInlineRename.tsx new file mode 100644 index 00000000000..1f771002f4c --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarInlineRename.tsx @@ -0,0 +1,422 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useRef, + useState, + type ReactNode, +} from "react"; +import { BbHttpError } from "@bb/sdk/browser"; +import { Icon } from "@bb/shared-ui/icon"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; + +interface SidebarRenameArgs { + kind: "thread" | "project" | "section" | "environment" | "machine"; + id: string; + name: string; + label: string; + onSave: (name: string) => Promise; + maxLength?: number; + placeholder?: string; + onClear?: () => Promise; + ownerKey?: string; +} + +interface RenameSession extends SidebarRenameArgs { + ownerKey: string; + version: number; + initialName: string; + draft: string; + isPending: boolean; + error: string | null; + cannotRetry: boolean; +} + +function renameError(error: unknown, kind: SidebarRenameArgs["kind"]) { + if (error instanceof BbHttpError) { + if ( + error.code === "section_name_conflict" || + (kind === "section" && error.status === 409) + ) { + return { + error: "A section with this name already exists.", + cannotRetry: false, + }; + } + if (error.status === 404 || error.status === 410) { + return { error: "This item no longer exists.", cannotRetry: true }; + } + if (error.status === 401 || error.status === 403) { + return { + error: "You do not have permission to rename this item.", + cannotRetry: true, + }; + } + } + return { error: "Could not save the name. Try again.", cannotRetry: false }; +} + +function useRenameController() { + const [session, setSession] = useState(null); + const sessionRef = useRef(null); + const versionRef = useRef(0); + const startRequestRef = useRef(0); + const pendingSaveRef = useRef | null>(null); + const update = useCallback((next: RenameSession | null) => { + sessionRef.current = next; + setSession(next); + }, []); + + const save = useCallback( + (version: number, clear = false): Promise => { + const current = sessionRef.current; + if (!current || current.version !== version) + return Promise.resolve(false); + if (current.isPending) + return pendingSaveRef.current ?? Promise.resolve(false); + if (current.cannotRetry) return Promise.resolve(false); + const value = current.draft.trim(); + const error = !value + ? "Name cannot be empty." + : current.maxLength && value.length > current.maxLength + ? `Name must be ${current.maxLength} characters or fewer.` + : null; + if (!clear && error) { + update({ ...current, error }); + return Promise.resolve(false); + } + if ( + (!clear && value === current.initialName.trim()) || + (clear && !current.initialName) + ) { + update(null); + return Promise.resolve(true); + } + if (clear && !current.onClear) return Promise.resolve(false); + update({ ...current, isPending: true, error: null }); + const pending = Promise.resolve() + .then(() => (clear ? current.onClear?.() : current.onSave(value))) + .then( + () => { + if (sessionRef.current?.version !== version) return false; + update(null); + return true; + }, + (error: unknown) => { + if (sessionRef.current?.version === version) { + update({ + ...current, + isPending: false, + ...renameError(error, current.kind), + }); + } + return false; + }, + ); + pendingSaveRef.current = pending; + return pending; + }, + [update], + ); + + const start = useCallback( + async (args: SidebarRenameArgs & { ownerKey: string }) => { + const request = ++startRequestRef.current; + const current = sessionRef.current; + if ( + current?.ownerKey === args.ownerKey && + current.kind === args.kind && + current.id === args.id + ) + return; + if (current && !(await save(current.version))) return; + if (request !== startRequestRef.current) return; + update({ + ...args, + version: ++versionRef.current, + initialName: args.name, + draft: args.name, + isPending: false, + error: null, + cannotRetry: false, + }); + }, + [save, update], + ); + + const change = useCallback( + (version: number, draft: string) => { + const current = sessionRef.current; + if (current?.version === version && !current.isPending) { + update({ ...current, draft, error: null, cannotRetry: false }); + } + }, + [update], + ); + + const cancel = useCallback( + (version: number) => { + const current = sessionRef.current; + if (current?.version !== version || current.isPending) return; + ++startRequestRef.current; + update(null); + }, + [update], + ); + + return { session, start, save, change, cancel }; +} + +type RenameController = ReturnType; +const SidebarRenameContext = createContext(null); + +export function SidebarRenameProvider({ children }: { children: ReactNode }) { + const controller = useRenameController(); + return ( + + {children} + + ); +} + +export function useSidebarRenameState() { + const session = useContext(SidebarRenameContext)?.session; + return session + ? { + kind: session.kind, + id: session.id, + initialName: session.initialName, + isPending: session.isPending, + } + : null; +} + +function SidebarRenameEditor({ + session, + controller, +}: { + session: RenameSession; + controller: RenameController; +}) { + const inputRef = useRef(null); + const groupRef = useRef(null); + const anchorRef = useRef(null); + const restoreFocusRef = useRef(false); + const composingRef = useRef(false); + const errorId = useId(); + + useEffect(() => { + const input = inputRef.current; + const row = input?.closest("[data-sidebar-rename-row]"); + anchorRef.current = + row?.querySelector("[data-sidebar-rename-anchor]") ?? null; + input?.focus({ preventScroll: true }); + input?.select(); + const frame = requestAnimationFrame(() => { + if ( + document.activeElement === document.body || + row?.contains(document.activeElement) + ) { + input?.focus({ preventScroll: true }); + input?.select(); + } + }); + return () => cancelAnimationFrame(frame); + }, []); + + const restoreFocus = () => { + const anchor = anchorRef.current; + requestAnimationFrame(() => { + if ( + anchor?.isConnected && + (document.activeElement === document.body || + groupRef.current?.contains(document.activeElement)) + ) { + anchor.focus({ preventScroll: true }); + } + }); + }; + + const submit = async (restore: boolean, clear = false) => { + restoreFocusRef.current = restore; + const saved = await controller.save(session.version, clear); + if (saved && restoreFocusRef.current) restoreFocus(); + }; + + const cancel = (restore: boolean) => { + if (session.isPending) return; + controller.cancel(session.version); + if (restore) restoreFocus(); + }; + + return ( + { + if (event.currentTarget.contains(event.relatedTarget)) return; + restoreFocusRef.current = false; + if (!session.isPending) void submit(false); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + onDragStart={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onKeyUp={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || composingRef.current) return; + if (event.key === "Escape") { + event.preventDefault(); + cancel(true); + } else if (event.key === "Enter" && event.target === inputRef.current) { + event.preventDefault(); + void submit(true); + } + }} + > + + controller.change(session.version, event.target.value) + } + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={() => { + composingRef.current = false; + }} + /> + + + {session.onClear && session.initialName && ( + + )} + {session.error && ( + + {session.error} + + )} + + ); +} + +export function useSidebarRename(args: SidebarRenameArgs) { + const shared = useContext(SidebarRenameContext); + const local = useRenameController(); + const controller = shared ?? local; + const generatedOwnerKey = useId(); + const ownerKey = args.ownerKey ?? generatedOwnerKey; + const session = controller.session; + const isEditing = + session?.ownerKey === ownerKey && + session.kind === args.kind && + session.id === args.id; + const startEditing = useCallback(() => { + void controller.start({ ...args, ownerKey }); + }, [args, controller.start, ownerKey]); + + useEffect(() => { + if ( + !shared && + session && + (session.kind !== args.kind || session.id !== args.id) + ) { + controller.cancel(session.version); + } + }, [args.id, args.kind, controller.cancel, session, shared]); + + return { + editor: isEditing ? ( + + ) : null, + isEditing, + isPending: isEditing && session.isPending, + startEditing, + }; +} diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx index 5eaec28fd5b..301b85e4f7f 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useLayoutEffect, + useRef, useState, type ReactNode, } from "react"; @@ -62,6 +63,7 @@ import { type SidebarOrganizationMode, } from "./sidebarCollapsedAtoms"; import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; +import { installSidebarRenameStoryApi } from "../../../.ladle/sidebar-rename-fixtures"; import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, @@ -269,6 +271,91 @@ const machineSidebarNavigation = { })), } satisfies SidebarBootstrapResponse; +const renameSidebarNavigation: SidebarBootstrapResponse = { + ...machineSidebarNavigation, + sections: [ + { id: "sec_story_review", name: "Review", createdAt: 1, updatedAt: 1 }, + { id: "sec_story_planning", name: "Planning", createdAt: 1, updatedAt: 1 }, + ], + personalProject: { + ...machineSidebarNavigation.personalProject, + threads: machineSidebarNavigation.personalProject.threads.map( + (thread, index) => ({ + ...thread, + sectionId: index === 0 ? "sec_story_review" : "sec_story_planning", + }), + ), + }, +}; + +function RenameSidebar() { + const [mode, setMode] = useState("project"); + const [ready, setReady] = useState(false); + const [failNext, setFailNext] = useState(false); + const failureRef = useRef(false); + useEffect(() => { + const cleanup = installSidebarRenameStoryApi({ + navigation: renameSidebarNavigation, + hosts: machineStoryHosts, + failNextSave: () => { + const fail = failureRef.current; + failureRef.current = false; + setFailNext(false); + return fail; + }, + }); + setReady(true); + return cleanup; + }, []); + return ( +
+
+ + +
+ {ready ? ( + + ) : ( + + )} +
+ ); +} + function SidebarFrame({ children, navigation }: SidebarFrameProps) { return ( @@ -504,9 +591,7 @@ export function Overview() { - - - + ); diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.tsx index 94a2562c337..09848de45d6 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.tsx @@ -9,6 +9,7 @@ import { type CSSProperties, type MouseEvent, type MouseEventHandler, + type ReactNode, } from "react"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; import { @@ -49,6 +50,7 @@ function stopActionsClick(event: MouseEvent) { interface SidebarSectionRowProps { name: string; + labelEditor?: ReactNode; label: string; depth: number; activity: CollapsedChildActivity; @@ -66,6 +68,7 @@ interface SidebarSectionRowProps { function SidebarSectionRowComponent({ name, + labelEditor, label, depth, activity, @@ -125,13 +128,13 @@ function SidebarSectionRowComponent({ }; const handleClickCapture = useCallback>( (event) => { - if (!consumeClickSuppression?.()) { + if (labelEditor || !consumeClickSuppression?.()) { return; } event.preventDefault(); event.stopPropagation(); }, - [consumeClickSuppression], + [consumeClickSuppression, labelEditor], ); const content = ( <> @@ -139,12 +142,29 @@ function SidebarSectionRowComponent({ type="button" aria-hidden="true" tabIndex={-1} + disabled={Boolean(labelEditor)} onClick={onToggleCollapsed} className="absolute inset-0 rounded-md outline-none ring-sidebar-ring focus-visible:ring-2" /> - {name} + {labelEditor ?? ( + { + event.preventDefault(); + event.stopPropagation(); + onRename(); + } + : undefined + } + > + {name} + + )} { + if (labelEditor) event.preventDefault(); + }} > ({ vi.mock("@/components/thread/ThreadActionsProvider", () => ({ useThreadActions: () => ({ - renameThread: mocks.renameThread, + renameThreadAsync: mocks.renameThread, }), })); import { TooltipProvider } from "@bb/shared-ui/tooltip"; @@ -53,7 +53,11 @@ vi.mock("@/components/thread/ThreadActionsMenu", () => ({ ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => ( <>{children} ), - ThreadActionsMenu: () => null, + ThreadActionsMenu: ({ onRename }: { onRename?: () => void }) => ( + + ), ThreadArchiveQuickAction: () => null, })); @@ -1423,7 +1427,7 @@ describe("ThreadRow", () => { expect(screen.getByLabelText("Unread thread succeeded")).not.toBeNull(); }); - it("edits the row title inline after a double click and commits on Enter", () => { + it("edits the row title inline after a double click and commits on Enter", async () => { renderThreadRow({ thread: createThread({ title: "Thread", titleFallback: "Thread" }), }); @@ -1435,14 +1439,61 @@ describe("ThreadRow", () => { fireEvent.change(input, { target: { value: "Renamed thread" } }); fireEvent.keyDown(input, { key: "Enter" }); - expect(mocks.renameThread).toHaveBeenCalledWith( - "thr_test", - "Renamed thread", - ); - expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + await waitFor(() => { + expect(mocks.renameThread).toHaveBeenCalledWith( + "thr_test", + "Renamed thread", + ); + }); + await waitFor(() => { + expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + }); expect(screen.getByText("Thread")).not.toBeNull(); }); + it("enters inline rename from the row menu without activating the thread", () => { + renderThreadRow({}); + + fireEvent.click(screen.getByRole("button", { name: "Rename thread" })); + + expect(screen.getByRole("textbox", { name: "Thread name" })).toHaveProperty( + "value", + "Thread", + ); + expect( + screen + .getByRole("link", { name: "Open Thread" }) + .getAttribute("aria-current"), + ).toBeNull(); + }); + + it("does not start a sortable drag while editing the title", () => { + const onPointerDown = vi.fn(); + renderThreadRow({ + options: { + ...DEFAULT_OPTIONS, + dragBindings: { + attributes: { + role: "button", + tabIndex: 0, + "aria-disabled": false, + "aria-pressed": undefined, + "aria-roledescription": "sortable", + "aria-describedby": "thread-sortable", + }, + disabled: false, + listeners: { onPointerDown }, + setActivatorNodeRef: vi.fn(), + }, + }, + }); + + fireEvent.doubleClick(screen.getByText("Thread")); + fireEvent.pointerDown(screen.getByRole("textbox", { name: "Thread name" })); + + expect(onPointerDown).not.toHaveBeenCalled(); + }); + it("cancels an inline row rename on Escape without saving", () => { renderThreadRow({ thread: createThread({ title: "Thread", titleFallback: "Thread" }), diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 9ff4051cf2a..eb05664e1b7 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -23,7 +23,7 @@ import { ThreadArchiveQuickAction, } from "@/components/thread/ThreadActionsMenu"; import { useThreadActions } from "@/components/thread/ThreadActionsProvider"; -import { useInlineThreadTitle } from "@/components/thread/InlineThreadTitle"; +import { useSidebarRename } from "./SidebarInlineRename"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -273,6 +273,7 @@ function renderThreadRowContainer({ style, }: ThreadRowContainerArgs) { const containerProps = { + "data-sidebar-rename-row": "", className, style, "data-sidebar-nest-target": nestTargetState ?? undefined, @@ -507,7 +508,7 @@ function ThreadRowComponent({ }: ThreadRowProps) { const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false); const [isContextActionsOpen, setIsContextActionsOpen] = useState(false); - const { renameThread } = useThreadActions(); + const { renameThreadAsync } = useThreadActions(); const setConversationCollapsed = useSetAtom( getThreadConversationCollapsedAtom(thread.id), ); @@ -535,15 +536,15 @@ function ThreadRowComponent({ ? `In project ${crossProjectName}` : "In another project"; const handleRename = useCallback( - (nextTitle: string) => { - renameThread(thread.id, nextTitle); - }, - [renameThread, thread.id], + (nextTitle: string) => renameThreadAsync(thread.id, nextTitle), + [renameThreadAsync, thread.id], ); - const { editor, isEditing, startEditing } = useInlineThreadTitle({ - onCommit: handleRename, - resetKey: thread.id, - title: threadTitle, + const { editor, isEditing, startEditing } = useSidebarRename({ + kind: "thread", + id: thread.id, + name: threadTitle, + label: "Thread name", + onSave: handleRename, }); const startTitleEditing = useCallback( (event: { preventDefault: () => void; stopPropagation: () => void }) => { @@ -612,7 +613,7 @@ function ThreadRowComponent({ const linkLabel = hasComposerDraft ? `Open ${labelTitle} (unsubmitted draft)` : `Open ${labelTitle}`; - const rowDragBindings = options.dragBindings; + const rowDragBindings = isEditing ? undefined : options.dragBindings; const nestTargetState = options.nestDrop?.state ?? null; const reorderPlacement = options.nestDrop?.reorderPlacement ?? null; const containerRef = useComposedRefs( @@ -660,6 +661,7 @@ function ThreadRowComponent({ to={getThreadRoutePath({ projectId, threadId: thread.id })} data-sidebar-thread-shortcut-target="" data-sidebar-thread-id={thread.id} + data-sidebar-rename-anchor="" onClick={(event) => { if (isEditing) { event.preventDefault(); @@ -726,6 +728,7 @@ function ThreadRowComponent({ ) : null} {parentOptions && hasChildren ? ( @@ -822,10 +827,11 @@ function ThreadRowComponent({ dragBindings: rowDragBindings, nestTargetState, reorderPlacement, - onClickCapture: options.consumeClickSuppression - ? handleRowClickCapture - : undefined, - onSplitDragPointerDown, + onClickCapture: + !isEditing && options.consumeClickSuppression + ? handleRowClickCapture + : undefined, + onSplitDragPointerDown: isEditing ? undefined : onSplitDragPointerDown, stickyLevel: parentOptions?.stickyLevel, style: rowStyle, }); @@ -835,6 +841,8 @@ function ThreadRowComponent({ thread={thread} onOpenInSplit={splitAvailable ? openInSplit : undefined} onOpenChange={setIsContextActionsOpen} + onRename={startEditing} + disabled={isEditing} > {row} diff --git a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx index 3f70fd0e190..216c9c32da6 100644 --- a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx +++ b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx @@ -55,6 +55,8 @@ interface TopLevelSidebarSectionCollapseControl { export interface TopLevelSidebarSectionProps { label: string; + labelEditor?: ReactNode; + onRename?: () => void; children: ReactNode; childrenInset?: boolean; showChildrenWhenCollapsed?: boolean; @@ -77,6 +79,8 @@ export interface TopLevelSidebarSectionProps { export function TopLevelSidebarSection({ label, + labelEditor, + onRename, children, childrenInset = true, showChildrenWhenCollapsed = false, @@ -135,13 +139,13 @@ export function TopLevelSidebarSection({ ) : null; const handleClickCapture = useCallback>( (event) => { - if (!consumeClickSuppression?.()) { + if (labelEditor || !consumeClickSuppression?.()) { return; } event.preventDefault(); event.stopPropagation(); }, - [consumeClickSuppression], + [consumeClickSuppression, labelEditor], ); const handleCollapseControlClick = useCallback< MouseEventHandler @@ -169,6 +173,7 @@ export function TopLevelSidebarSection({ ref={sectionRef} style={sectionStyle} data-sidebar-section-id={sectionId} + data-sidebar-rename-row="" className={cn( "group/sidebar-section min-w-0 rounded-md transition-colors", isDropTargetActive && "bg-sidebar-accent/60", @@ -192,12 +197,28 @@ export function TopLevelSidebarSection({ {...(dragBindings?.listeners ?? {})} > - - {label} - + {labelEditor ?? ( + { + event.preventDefault(); + event.stopPropagation(); + onRename(); + } + : undefined + } + > + {label} + + )} {collapseControl ? ( + {editor ?? {thread.title}} + {!context && ( + + )} + + ); + return context ? ( + + {row} + + ) : ( + row + ); +} + async function openMoveSubmenu() { const trigger = await screen.findByRole("menuitem", { name: "Move to section", @@ -92,6 +131,62 @@ afterEach(() => { }); describe("ThreadActionsMenu", () => { + it("keeps the existing rename dialog for callers without an inline override", async () => { + renderWide(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thread actions" }), + { button: 0 }, + ); + + fireEvent.click(screen.getByRole("menuitem", { name: "Rename" })); + + await waitFor(() => { + expect(threadActions.requestRename).toHaveBeenCalledWith(thread); + }); + }); + + it.each([false, true])( + "hands keyboard focus from the wide menu to inline rename (context: %s)", + async (context) => { + renderWide(); + if (context) { + fireEvent.contextMenu(screen.getByTestId("thread-row")); + } else { + fireEvent.pointerDown( + screen.getByRole("button", { name: "Thread actions" }), + { button: 0 }, + ); + } + + fireEvent.keyDown(screen.getByRole("menuitem", { name: "Rename" }), { + key: "Enter", + }); + + const input = await screen.findByRole("textbox", { name: "Thread name" }); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(input).toHaveProperty("value", "Move me"); + expect(threadActions.requestRename).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + "hands focus from the compact drawer to inline rename (context: %s)", + async (context) => { + renderCompact(); + if (context) { + fireEvent.contextMenu(screen.getByTestId("thread-row")); + } else { + fireEvent.click(screen.getByRole("button", { name: "Thread actions" })); + } + + fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + + const input = await screen.findByRole("textbox", { name: "Thread name" }); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(threadActions.requestRename).not.toHaveBeenCalled(); + }, + ); + it("copies the canonical thread URL from every menu instance", () => { renderWide(); diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index 52162e6d1df..c0f418a0340 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -3,7 +3,7 @@ import { ActionMenuSeparator, } from "@/components/ui/action-menu-items"; import type { Thread } from "@bb/domain"; -import { useCallback, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ReactNode } from "react"; import { ContextMenu, ContextMenuContent, @@ -40,6 +40,7 @@ import { useThreadSectionMove } from "./ThreadSectionMoveProvider"; interface ThreadActionsMenuBaseProps { thread: Thread; onOpenInSplit?: () => void; + onRename?: () => void; } export interface ThreadActionsMenuResponsiveAction { @@ -56,6 +57,7 @@ interface ThreadActionsMenuProps extends ThreadActionsMenuBaseProps { interface ThreadActionsContextMenuProps extends ThreadActionsMenuBaseProps { children: ReactNode; + disabled?: boolean; onOpenChange?: (open: boolean) => void; } @@ -174,6 +176,7 @@ function ThreadSectionMoveMenu({ function ThreadActionsMenuItems({ thread, onOpenInSplit, + onRename, compactStep = "actions", onCompactStepChange, responsiveActions = [], @@ -283,6 +286,10 @@ function ThreadActionsMenuItems({ surface={surface} icon="Edit" onSelect={() => { + if (onRename) { + onRename(); + return; + } window.setTimeout(() => { requestRename(thread); }, 0); @@ -320,11 +327,18 @@ function ThreadActionsMenuItems({ ); } -function useThreadActionsMenuLifecycle(onOpenChange?: (open: boolean) => void) { +function useThreadActionsMenuLifecycle( + onOpenChange?: (open: boolean) => void, + onRename?: () => void, +) { const [compactStep, setCompactStep] = useState("actions"); + const renameSelectedRef = useRef(false); const handleOpenChange = useCallback( (open: boolean) => { + if (open) { + renameSelectedRef.current = false; + } if (!open) { setCompactStep("actions"); } @@ -333,7 +347,23 @@ function useThreadActionsMenuLifecycle(onOpenChange?: (open: boolean) => void) { [onOpenChange], ); - return { compactStep, setCompactStep, handleOpenChange }; + const handleRename = useCallback(() => { + renameSelectedRef.current = true; + onRename?.(); + }, [onRename]); + const handleCloseAutoFocus = useCallback((event: Event) => { + if (renameSelectedRef.current) { + event.preventDefault(); + } + }, []); + + return { + compactStep, + setCompactStep, + handleOpenChange, + handleCloseAutoFocus, + handleRename: onRename ? handleRename : undefined, + }; } export function ThreadArchiveQuickAction({ @@ -379,12 +409,18 @@ export function ThreadArchiveQuickAction({ export function ThreadActionsMenu({ thread, onOpenInSplit, + onRename, responsiveActions, onOpenChange, triggerClassName, }: ThreadActionsMenuProps) { - const { compactStep, setCompactStep, handleOpenChange } = - useThreadActionsMenuLifecycle(onOpenChange); + const { + compactStep, + setCompactStep, + handleOpenChange, + handleCloseAutoFocus, + handleRename, + } = useThreadActionsMenuLifecycle(onOpenChange, onRename); return ( @@ -409,10 +445,11 @@ export function ThreadActionsMenu({ /> - + - {children} - + + + {children} + + diff --git a/apps/app/src/components/thread/ThreadActionsProvider.navigation.test.tsx b/apps/app/src/components/thread/ThreadActionsProvider.navigation.test.tsx index 9385df5a9d4..c353ab61f12 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.navigation.test.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.navigation.test.tsx @@ -30,7 +30,11 @@ vi.mock("@/components/dialogs/ThreadRenameDialog", () => ({ })); vi.mock("@/hooks/mutations/thread-state-mutations", () => { - const mutationResult = { isPending: false, mutate: vi.fn() }; + const mutationResult = { + isPending: false, + mutate: vi.fn(), + mutateAsync: vi.fn(), + }; const mutation = () => mutationResult; return { useArchiveThreadAndChildren: mutation, diff --git a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx index 1634e9bd576..eb1d44d1148 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.test.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.test.tsx @@ -66,7 +66,11 @@ vi.mock("@/hooks/mutations/thread-state-mutations", async (importOriginal) => { useMarkThreadUnread: () => ({ mutate: mocks.mutation }), usePinThread: () => ({ mutate: mocks.mutation }), useUnpinThread: () => ({ mutate: mocks.mutation }), - useUpdateThread: () => ({ isPending: false, mutate: mocks.mutation }), + useUpdateThread: () => ({ + isPending: false, + mutate: mocks.mutation, + mutateAsync: mocks.mutation, + }), }; }); diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 007aa47ad2c..27d6781059f 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -48,6 +48,7 @@ import { useRouteNavigate } from "@/components/ui/app-route-anchor"; export interface ThreadActionsContextValue { archiveThreadAndChildren: (thread: Thread) => void; renameThread: (threadId: string, title: string) => void; + renameThreadAsync: (threadId: string, title: string) => Promise; requestRename: (thread: Thread) => void; requestDelete: (thread: Thread) => void; unarchiveThread: (thread: Thread) => void; @@ -103,6 +104,7 @@ export function ThreadActionsProvider({ const unpinThread = useUnpinThread(); const deleteThread = useDeleteThread(); const updateThread = useUpdateThread(); + const inlineRenameThread = useUpdateThread({ showErrorToast: false }); const threadActionContextAbortRef = useRef(null); const { mutateAsync: archiveThreadAndChildrenMutateAsync } = archiveThreadAndChildrenMutation; @@ -113,6 +115,7 @@ export function ThreadActionsProvider({ const { mutate: unpinMutate } = unpinThread; const { mutate: deleteMutate } = deleteThread; const { mutate: updateMutate } = updateThread; + const { mutateAsync: inlineRenameMutateAsync } = inlineRenameThread; const renameDialog = useDialogState(); const deleteDialog = useDialogState(); @@ -166,6 +169,13 @@ export function ThreadActionsProvider({ [updateMutate], ); + const renameThreadAsync = useCallback( + async (threadId: string, title: string) => { + await inlineRenameMutateAsync({ id: threadId, title }); + }, + [inlineRenameMutateAsync], + ); + const submitRename = useCallback( (threadId: string, payload: ThreadRenameDialogPayload) => { updateMutate( @@ -356,24 +366,30 @@ export function ThreadActionsProvider({ const toggleRead = useCallback( (thread: Thread) => { if (getThreadReadToggleAction(thread) === "mark_unread") { - markUnreadMutate({ threadId: thread.id }, { + markUnreadMutate( + { threadId: thread.id }, + { + onError: (error) => { + showMutationErrorToast({ + error, + fallbackMessage: "Failed to mark thread unread", + }); + }, + }, + ); + return; + } + markReadMutate( + { threadId: thread.id }, + { onError: (error) => { showMutationErrorToast({ error, - fallbackMessage: "Failed to mark thread unread", + fallbackMessage: "Failed to mark thread read", }); }, - }); - return; - } - markReadMutate({ threadId: thread.id }, { - onError: (error) => { - showMutationErrorToast({ - error, - fallbackMessage: "Failed to mark thread read", - }); }, - }); + ); }, [markReadMutate, markUnreadMutate], ); @@ -392,6 +408,7 @@ export function ThreadActionsProvider({ const value = useMemo( () => ({ renameThread, + renameThreadAsync, requestRename, requestDelete, archiveThreadAndChildren: archiveThreadAndChildrenAction, @@ -402,6 +419,7 @@ export function ThreadActionsProvider({ [ archiveThreadAndChildrenAction, renameThread, + renameThreadAsync, requestRename, requestDelete, togglePin, diff --git a/apps/app/src/components/ui/compact-long-press-menu.test.tsx b/apps/app/src/components/ui/compact-long-press-menu.test.tsx index 0b21e32e0aa..332b0beeb70 100644 --- a/apps/app/src/components/ui/compact-long-press-menu.test.tsx +++ b/apps/app/src/components/ui/compact-long-press-menu.test.tsx @@ -15,10 +15,12 @@ import { CompactLongPressMenu } from "./compact-long-press-menu"; const LONG_PRESS_MS = 700; function renderRow({ + disabled = false, onRowClick = vi.fn(), onOpenChange = vi.fn(), onRename = vi.fn(), }: { + disabled?: boolean; onRowClick?: () => void; onOpenChange?: (open: boolean) => void; onRename?: () => void; @@ -29,6 +31,7 @@ function renderRow({ const utils = render( Rename
} @@ -66,6 +69,24 @@ afterEach(() => { }); describe("CompactLongPressMenu", () => { + it("preserves touch selection and the native context menu while disabled", () => { + vi.useFakeTimers(); + const { row, onOpenChange } = renderRow({ disabled: true }); + + touchPointerDown(row); + act(() => { + vi.advanceTimersByTime(LONG_PRESS_MS); + }); + const event = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + fireEvent(row, event); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + it("mounts nothing for the menu until a long press, then opens the drawer without a modal takeover", () => { vi.useFakeTimers(); const { row, onOpenChange } = renderRow(); diff --git a/apps/app/src/components/ui/compact-long-press-menu.tsx b/apps/app/src/components/ui/compact-long-press-menu.tsx index 0d8ee1bdb22..c70c140fe9d 100644 --- a/apps/app/src/components/ui/compact-long-press-menu.tsx +++ b/apps/app/src/components/ui/compact-long-press-menu.tsx @@ -23,6 +23,7 @@ const claimedPressEvents = new WeakSet(); interface CompactLongPressMenuProps { children: ReactNode; + disabled?: boolean; items: ReactNode; label: string; onOpenChange?: (open: boolean) => void; @@ -30,6 +31,7 @@ interface CompactLongPressMenuProps { export function CompactLongPressMenu({ children, + disabled = false, items, label, onOpenChange, @@ -52,6 +54,13 @@ export function CompactLongPressMenu({ useEffect(() => clearPress, [clearPress]); + useEffect(() => { + if (disabled) { + clearPress(); + suppressClickUntilRef.current = 0; + } + }, [clearPress, disabled]); + const handleOpenChange = useCallback( (nextOpen: boolean) => { setOpen(nextOpen); @@ -68,6 +77,9 @@ export function CompactLongPressMenu({ const handlePointerDown = useCallback( (event: ReactPointerEvent) => { + if (disabled) { + return; + } if (event.pointerType !== "touch" && event.pointerType !== "pen") { return; } @@ -95,7 +107,7 @@ export function CompactLongPressMenu({ openMenu(); }, LONG_PRESS_MS); }, - [clearPress, openMenu], + [clearPress, disabled, openMenu], ); const handlePointerMove = useCallback( @@ -125,7 +137,7 @@ export function CompactLongPressMenu({ const handleContextMenu = useCallback( (event: ReactMouseEvent) => { - if (event.defaultPrevented) { + if (disabled || event.defaultPrevented) { return; } event.preventDefault(); @@ -135,7 +147,7 @@ export function CompactLongPressMenu({ } openMenu(); }, - [openMenu], + [disabled, openMenu], ); const handleClickCapture = useCallback( diff --git a/apps/app/src/hooks/cache-owners/project-cache-owner.ts b/apps/app/src/hooks/cache-owners/project-cache-owner.ts index 9603f8afc57..92972444be9 100644 --- a/apps/app/src/hooks/cache-owners/project-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/project-cache-owner.ts @@ -124,6 +124,27 @@ export function applyProjectCreateResult({ ); } +export function applyProjectUpdateResult({ + project, + queryClient, +}: ApplyProjectCreateResultArgs): void { + queryClient.setQueryData(projectsQueryKey(), (projects) => + projects?.map((current) => (current.id === project.id ? project : current)), + ); + queryClient.setQueryData( + sidebarNavigationQueryKey(), + (navigation) => + navigation + ? { + ...navigation, + projects: navigation.projects.map((current) => + current.id === project.id ? { ...current, ...project } : current, + ), + } + : navigation, + ); +} + export function applyProjectDeleteResult({ projectId, queryClient, diff --git a/apps/app/src/hooks/mutations/host-mutations.ts b/apps/app/src/hooks/mutations/host-mutations.ts index a483dae9186..3b9a1b1ce96 100644 --- a/apps/app/src/hooks/mutations/host-mutations.ts +++ b/apps/app/src/hooks/mutations/host-mutations.ts @@ -4,6 +4,7 @@ import { apiClient } from "@/lib/api-server"; import { request } from "@/lib/api"; import { sdk } from "@/lib/sdk"; import { invalidateHostListQueries } from "../cache-owners/mutation-cache-effects"; +import { hostsQueryKey } from "../queries/query-keys"; interface RenameHostRequest { hostId: string; @@ -19,7 +20,10 @@ export function useRenameHost() { }, mutationFn: ({ hostId, name }: RenameHostRequest) => sdk.hosts.update({ hostId, name }), - onSuccess: () => { + onSuccess: (host) => { + queryClient.setQueryData(hostsQueryKey(), (hosts) => + hosts?.map((current) => (current.id === host.id ? host : current)), + ); invalidateHostListQueries({ queryClient }); }, }); diff --git a/apps/app/src/hooks/mutations/project-mutations.ts b/apps/app/src/hooks/mutations/project-mutations.ts index f253e5b8baa..c9679a3fba2 100644 --- a/apps/app/src/hooks/mutations/project-mutations.ts +++ b/apps/app/src/hooks/mutations/project-mutations.ts @@ -11,6 +11,7 @@ import { registerLocalAttachmentPreview } from "@/lib/attachment-local-previews" import { applyProjectCreateResult, applyProjectDeleteResult, + applyProjectUpdateResult, } from "../cache-owners/project-cache-owner"; import { invalidateProjectListQueries, @@ -63,16 +64,18 @@ export function useCreateProject() { }); } -export function useUpdateProject() { +export function useUpdateProject(options?: { showErrorToast?: boolean }) { const queryClient = useQueryClient(); return useMutation({ meta: { errorMessage: "Failed to update project.", + showErrorToast: options?.showErrorToast ?? true, }, mutationFn: ({ id, ...request }: UpdateProjectMutationRequest) => sdk.projects.update({ projectId: id, ...request }), - onSuccess: (_data, variables) => { + onSuccess: (project, variables) => { + applyProjectUpdateResult({ project, queryClient }); invalidateProjectUpdateQueries({ projectId: variables.id, queryClient }); }, }); diff --git a/apps/app/src/hooks/mutations/thread-section-mutations.ts b/apps/app/src/hooks/mutations/thread-section-mutations.ts index ef78cd18533..a620327ca02 100644 --- a/apps/app/src/hooks/mutations/thread-section-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-section-mutations.ts @@ -3,8 +3,10 @@ import type { CreateThreadSectionRequest, DeleteThreadSectionRequest, UpdateThreadSectionRequest, + SidebarBootstrapResponse, } from "@bb/server-contract"; import { sdk } from "@/lib/sdk"; +import { sidebarNavigationQueryKey } from "../queries/query-keys"; import { invalidateProjectListQueries, invalidateThreadListQueries, @@ -43,7 +45,21 @@ export function useUpdateThreadSection() { }, mutationFn: (request: UpdateThreadSectionRequest) => sdk.threadSections.update(request), - onSuccess: () => { + onSuccess: (section) => { + queryClient.setQueryData( + sidebarNavigationQueryKey(), + (navigation) => + navigation + ? { + ...navigation, + sections: navigation.sections.map((current) => + current.id === section.id + ? { ...current, name: section.name } + : current, + ), + } + : navigation, + ); invalidateThreadSectionQueries(queryClient); }, }); diff --git a/apps/app/src/hooks/mutations/thread-state-mutations.ts b/apps/app/src/hooks/mutations/thread-state-mutations.ts index b612814aa1a..ee9b6d392ed 100644 --- a/apps/app/src/hooks/mutations/thread-state-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-state-mutations.ts @@ -57,6 +57,7 @@ interface MoveThreadToSectionRequest { interface UpdateThreadMutationOptions { errorMessage?: string | undefined; lifecycleOperation?: LifecycleErrorOperation | undefined; + showErrorToast?: boolean; } interface ArchiveThreadAndChildrenMutationRequest { @@ -84,6 +85,7 @@ export function useUpdateThread(options?: UpdateThreadMutationOptions) { >({ meta: { errorMessage: options?.errorMessage ?? "Failed to update thread.", + showErrorToast: options?.showErrorToast ?? true, ...(options?.lifecycleOperation ? { lifecycleOperation: options.lifecycleOperation } : {}), From 6b519e3ae03bf70ebde784ba08a8267fc8ec8c52 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 15:59:10 -0700 Subject: [PATCH 02/20] Preserve inline editors while menus release focus --- .../sidebar/SidebarInlineRename.test.tsx | 1 + .../sidebar/SidebarInlineRename.tsx | 21 +++++++++------- .../src/components/sidebar/ThreadRow.test.tsx | 4 +--- .../hooks/cache-owners/project-cache-owner.ts | 24 +++++++++++++++++++ .../cache-owners/system-cache-effects.ts | 11 ++++++++- .../app/src/hooks/mutations/host-mutations.ts | 6 ++--- .../mutations/thread-section-mutations.ts | 18 ++------------ 7 files changed, 52 insertions(+), 33 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarInlineRename.test.tsx b/apps/app/src/components/sidebar/SidebarInlineRename.test.tsx index 641f1d55593..c7f8ec1d683 100644 --- a/apps/app/src/components/sidebar/SidebarInlineRename.test.tsx +++ b/apps/app/src/components/sidebar/SidebarInlineRename.test.tsx @@ -225,6 +225,7 @@ describe("sidebar inline rename", () => { ); start(); const destination = screen.getByRole("button", { name: "Elsewhere" }); + await act(async () => new Promise(requestAnimationFrame)); act(() => destination.focus()); await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); await act(async () => pending.resolve()); diff --git a/apps/app/src/components/sidebar/SidebarInlineRename.tsx b/apps/app/src/components/sidebar/SidebarInlineRename.tsx index 1f771002f4c..3f32cd924b6 100644 --- a/apps/app/src/components/sidebar/SidebarInlineRename.tsx +++ b/apps/app/src/components/sidebar/SidebarInlineRename.tsx @@ -207,6 +207,7 @@ function SidebarRenameEditor({ const anchorRef = useRef(null); const restoreFocusRef = useRef(false); const composingRef = useRef(false); + const openingRef = useRef(true); const errorId = useId(); useEffect(() => { @@ -217,6 +218,7 @@ function SidebarRenameEditor({ input?.focus({ preventScroll: true }); input?.select(); const frame = requestAnimationFrame(() => { + openingRef.current = false; if ( document.activeElement === document.body || row?.contains(document.activeElement) @@ -260,7 +262,11 @@ function SidebarRenameEditor({ className="relative z-50 flex min-w-0 flex-1 items-center gap-1 text-sm font-normal" aria-busy={session.isPending} onBlur={(event) => { - if (event.currentTarget.contains(event.relatedTarget)) return; + if ( + openingRef.current || + event.currentTarget.contains(event.relatedTarget) + ) + return; restoreFocusRef.current = false; if (!session.isPending) void submit(false); }} @@ -317,7 +323,6 @@ function SidebarRenameEditor({ + , ); - start(); + await start(); const destination = screen.getByRole("button", { name: "Elsewhere" }); await act(async () => new Promise(requestAnimationFrame)); act(() => destination.focus()); @@ -232,10 +232,10 @@ describe("sidebar inline rename", () => { expect(document.activeElement).toBe(destination); }); - it("preserves a draft through external updates and displays the latest name after cancel", () => { + it("preserves a draft through external updates and displays the latest name after cancel", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const { rerender } = render(); - start("My draft"); + await start("My draft"); rerender(); expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe( "My draft", @@ -254,7 +254,7 @@ describe("sidebar inline rename", () => { , ); - const input = start(" "); + const input = await start(" "); fireEvent.click(screen.getByRole("button", { name: "Rename second" })); await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull()); expect(screen.queryByRole("textbox", { name: "second name" })).toBeNull(); @@ -269,14 +269,14 @@ describe("sidebar inline rename", () => { expect(screen.getByLabelText("Active rename").textContent).toBe("second"); }); - it("preserves the provider draft when its owning row unmounts and remounts", () => { + it("preserves the provider draft when its owning row unmounts and remounts", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const { rerender } = render( , ); - start("Keep my draft"); + await start("Keep my draft"); rerender({null}); rerender( @@ -293,7 +293,7 @@ describe("sidebar inline rename", () => { const onSave = vi.fn().mockResolvedValue(undefined); const onClear = vi.fn().mockResolvedValue(undefined); render(); - fireEvent.keyDown(start(" "), { key: "Enter" }); + fireEvent.keyDown(await start(" "), { key: "Enter" }); expect(onClear).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Clear custom name" })); await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); diff --git a/apps/app/src/components/sidebar/SidebarInlineRename.tsx b/apps/app/src/components/sidebar/SidebarInlineRename.tsx index 3f32cd924b6..a341dd10721 100644 --- a/apps/app/src/components/sidebar/SidebarInlineRename.tsx +++ b/apps/app/src/components/sidebar/SidebarInlineRename.tsx @@ -1,5 +1,7 @@ import { createContext, + lazy, + Suspense, useCallback, useContext, useEffect, @@ -8,11 +10,8 @@ import { useState, type ReactNode, } from "react"; -import { BbHttpError } from "@bb/sdk/browser"; -import { Icon } from "@bb/shared-ui/icon"; -import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; +const loadRenameEditor = () => import("./SidebarRenameEditor"); +const SidebarRenameEditor = lazy(loadRenameEditor); interface SidebarRenameArgs { kind: "thread" | "project" | "section" | "environment" | "machine"; @@ -26,7 +25,7 @@ interface SidebarRenameArgs { ownerKey?: string; } -interface RenameSession extends SidebarRenameArgs { +export interface RenameSession extends SidebarRenameArgs { ownerKey: string; version: number; initialName: string; @@ -36,30 +35,6 @@ interface RenameSession extends SidebarRenameArgs { cannotRetry: boolean; } -function renameError(error: unknown, kind: SidebarRenameArgs["kind"]) { - if (error instanceof BbHttpError) { - if ( - error.code === "section_name_conflict" || - (kind === "section" && error.status === 409) - ) { - return { - error: "A section with this name already exists.", - cannotRetry: false, - }; - } - if (error.status === 404 || error.status === 410) { - return { error: "This item no longer exists.", cannotRetry: true }; - } - if (error.status === 401 || error.status === 403) { - return { - error: "You do not have permission to rename this item.", - cannotRetry: true, - }; - } - } - return { error: "Could not save the name. Try again.", cannotRetry: false }; -} - function useRenameController() { const [session, setSession] = useState(null); const sessionRef = useRef(null); @@ -106,7 +81,8 @@ function useRenameController() { update(null); return true; }, - (error: unknown) => { + async (error: unknown) => { + const { renameError } = await loadRenameEditor(); if (sessionRef.current?.version === version) { update({ ...current, @@ -171,7 +147,7 @@ function useRenameController() { return { session, start, save, change, cancel }; } -type RenameController = ReturnType; +export type RenameController = ReturnType; const SidebarRenameContext = createContext(null); export function SidebarRenameProvider({ children }: { children: ReactNode }) { @@ -195,196 +171,6 @@ export function useSidebarRenameState() { : null; } -function SidebarRenameEditor({ - session, - controller, -}: { - session: RenameSession; - controller: RenameController; -}) { - const inputRef = useRef(null); - const groupRef = useRef(null); - const anchorRef = useRef(null); - const restoreFocusRef = useRef(false); - const composingRef = useRef(false); - const openingRef = useRef(true); - const errorId = useId(); - - useEffect(() => { - const input = inputRef.current; - const row = input?.closest("[data-sidebar-rename-row]"); - anchorRef.current = - row?.querySelector("[data-sidebar-rename-anchor]") ?? null; - input?.focus({ preventScroll: true }); - input?.select(); - const frame = requestAnimationFrame(() => { - openingRef.current = false; - if ( - document.activeElement === document.body || - row?.contains(document.activeElement) - ) { - input?.focus({ preventScroll: true }); - input?.select(); - } - }); - return () => cancelAnimationFrame(frame); - }, []); - - const restoreFocus = () => { - const anchor = anchorRef.current; - requestAnimationFrame(() => { - if ( - anchor?.isConnected && - (document.activeElement === document.body || - groupRef.current?.contains(document.activeElement)) - ) { - anchor.focus({ preventScroll: true }); - } - }); - }; - - const submit = async (restore: boolean, clear = false) => { - restoreFocusRef.current = restore; - const saved = await controller.save(session.version, clear); - if (saved && restoreFocusRef.current) restoreFocus(); - }; - - const cancel = (restore: boolean) => { - if (session.isPending) return; - controller.cancel(session.version); - if (restore) restoreFocus(); - }; - - return ( - { - if ( - openingRef.current || - event.currentTarget.contains(event.relatedTarget) - ) - return; - restoreFocusRef.current = false; - if (!session.isPending) void submit(false); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onDoubleClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onPointerDown={(event) => event.stopPropagation()} - onPointerUp={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - onDragStart={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onKeyUp={(event) => event.stopPropagation()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || composingRef.current) return; - if (event.key === "Escape") { - event.preventDefault(); - cancel(true); - } else if (event.key === "Enter" && event.target === inputRef.current) { - event.preventDefault(); - void submit(true); - } - }} - > - - controller.change(session.version, event.target.value) - } - onCompositionStart={() => { - composingRef.current = true; - }} - onCompositionEnd={() => { - composingRef.current = false; - }} - /> - - - {session.onClear && session.initialName && ( - - )} - {session.error && ( - - {session.error} - - )} - - ); -} - export function useSidebarRename(args: SidebarRenameArgs) { const shared = useContext(SidebarRenameContext); const local = useRenameController(); @@ -412,11 +198,19 @@ export function useSidebarRename(args: SidebarRenameArgs) { return { editor: isEditing ? ( - + + {session.initialName} + + } + > + + ) : null, isEditing, isPending: isEditing && session.isPending, diff --git a/apps/app/src/components/sidebar/SidebarRenameEditor.tsx b/apps/app/src/components/sidebar/SidebarRenameEditor.tsx new file mode 100644 index 00000000000..4961f6bb2ed --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarRenameEditor.tsx @@ -0,0 +1,221 @@ +import { useEffect, useId, useRef } from "react"; +import { BbHttpError } from "@bb/sdk/browser"; +import { Icon } from "@bb/shared-ui/icon"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { SIDEBAR_CONTROL_BUTTON_CLASS } from "./sidebarRowClasses"; +import type { RenameSession, RenameController } from "./SidebarInlineRename"; + +export function renameError(error: unknown, kind: RenameSession["kind"]) { + if (error instanceof BbHttpError) { + if ( + error.code === "section_name_conflict" || + (kind === "section" && error.status === 409) + ) { + return { + error: "A section with this name already exists.", + cannotRetry: false, + }; + } + if (error.status === 404 || error.status === 410) { + return { error: "This item no longer exists.", cannotRetry: true }; + } + if (error.status === 401 || error.status === 403) { + return { + error: "You do not have permission to rename this item.", + cannotRetry: true, + }; + } + } + return { error: "Could not save the name. Try again.", cannotRetry: false }; +} + +export default function SidebarRenameEditor({ + session, + controller, +}: { + session: RenameSession; + controller: RenameController; +}) { + const inputRef = useRef(null); + const groupRef = useRef(null); + const anchorRef = useRef(null); + const restoreFocusRef = useRef(false); + const composingRef = useRef(false); + const openingRef = useRef(true); + const errorId = useId(); + + useEffect(() => { + const input = inputRef.current; + const row = input?.closest("[data-sidebar-rename-row]"); + anchorRef.current = + row?.querySelector("[data-sidebar-rename-anchor]") ?? null; + input?.focus({ preventScroll: true }); + input?.select(); + const frame = requestAnimationFrame(() => { + openingRef.current = false; + if ( + document.activeElement === document.body || + row?.contains(document.activeElement) + ) { + input?.focus({ preventScroll: true }); + input?.select(); + } + }); + return () => cancelAnimationFrame(frame); + }, []); + + const restoreFocus = () => { + const anchor = anchorRef.current; + requestAnimationFrame(() => { + if ( + anchor?.isConnected && + (document.activeElement === document.body || + groupRef.current?.contains(document.activeElement)) + ) { + anchor.focus({ preventScroll: true }); + } + }); + }; + + const submit = async (restore: boolean, clear = false) => { + restoreFocusRef.current = restore; + const saved = await controller.save(session.version, clear); + if (saved && restoreFocusRef.current) restoreFocus(); + }; + + const cancel = (restore: boolean) => { + if (session.isPending) return; + controller.cancel(session.version); + if (restore) restoreFocus(); + }; + + return ( + { + if ( + openingRef.current || + event.currentTarget.contains(event.relatedTarget) + ) + return; + restoreFocusRef.current = false; + if (!session.isPending) void submit(false); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + onDragStart={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onKeyUp={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || composingRef.current) return; + if (event.key === "Escape") { + event.preventDefault(); + cancel(true); + } else if (event.key === "Enter" && event.target === inputRef.current) { + event.preventDefault(); + void submit(true); + } + }} + > + + controller.change(session.version, event.target.value) + } + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={() => { + composingRef.current = false; + }} + /> + + + {session.onClear && session.initialName && ( + + )} + {session.error && ( + + {session.error} + + )} + + ); +} From 797fc9d810e76f58ccd4e28d03e38cff27cc3ff4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:08:03 -0700 Subject: [PATCH 04/20] Wait for editor loading in row interaction tests --- .../src/components/sidebar/ThreadRow.test.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 327bd549ea7..e6377867270 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -1509,7 +1509,7 @@ describe("ThreadRow", () => { }); fireEvent.doubleClick(screen.getByText("Thread")); - const input = screen.getByRole("textbox", { name: "Thread name" }); + const input = await screen.findByRole("textbox", { name: "Thread name" }); expect(input).toHaveProperty("value", "Thread"); fireEvent.change(input, { target: { value: "Renamed thread" } }); @@ -1527,15 +1527,14 @@ describe("ThreadRow", () => { expect(screen.getByText("Thread")).not.toBeNull(); }); - it("enters inline rename from the row menu without activating the thread", () => { + it("enters inline rename from the row menu without activating the thread", async () => { renderThreadRow({}); fireEvent.click(screen.getByRole("button", { name: "Rename thread" })); - expect(screen.getByRole("textbox", { name: "Thread name" })).toHaveProperty( - "value", - "Thread", - ); + expect( + await screen.findByRole("textbox", { name: "Thread name" }), + ).toHaveProperty("value", "Thread"); expect( screen .getByRole("link", { name: "Open Thread" }) @@ -1543,7 +1542,7 @@ describe("ThreadRow", () => { ).toBeNull(); }); - it("does not start a sortable drag while editing the title", () => { + it("does not start a sortable drag while editing the title", async () => { const onPointerDown = vi.fn(); renderThreadRow({ options: { @@ -1565,18 +1564,20 @@ describe("ThreadRow", () => { }); fireEvent.doubleClick(screen.getByText("Thread")); - fireEvent.pointerDown(screen.getByRole("textbox", { name: "Thread name" })); + fireEvent.pointerDown( + await screen.findByRole("textbox", { name: "Thread name" }), + ); expect(onPointerDown).not.toHaveBeenCalled(); }); - it("cancels an inline row rename on Escape without saving", () => { + it("cancels an inline row rename on Escape without saving", async () => { renderThreadRow({ thread: createThread({ title: "Thread", titleFallback: "Thread" }), }); fireEvent.doubleClick(screen.getByText("Thread")); - const input = screen.getByRole("textbox", { name: "Thread name" }); + const input = await screen.findByRole("textbox", { name: "Thread name" }); fireEvent.change(input, { target: { value: "Scratch name" } }); fireEvent.keyDown(input, { key: "Escape" }); @@ -1585,7 +1586,7 @@ describe("ThreadRow", () => { expect(screen.getByText("Thread")).not.toBeNull(); }); - it("starts a rename from a second click after the row remounts", () => { + it("starts a rename from a second click after the row remounts", async () => { const thread = createThread({ title: "Thread", titleFallback: "Thread" }); const { rerenderThreadRow } = renderThreadRow({ thread }); const link = screen.getByRole("link", { name: "Open Thread" }); @@ -1594,9 +1595,8 @@ describe("ThreadRow", () => { rerenderThreadRow(thread); fireEvent.click(screen.getByRole("link", { name: "Open Thread" })); - expect(screen.getByRole("textbox", { name: "Thread name" })).toHaveProperty( - "value", - "Thread", - ); + expect( + await screen.findByRole("textbox", { name: "Thread name" }), + ).toHaveProperty("value", "Thread"); }); }); From 012b5a2319c430ca360ed716d876ce97457cd307 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:22:31 -0700 Subject: [PATCH 05/20] Start sidebar editors after menus release focus --- .../src/components/sidebar/ProjectList.tsx | 6 ++--- .../sidebar/ProjectRow.interactions.test.tsx | 3 +++ .../app/src/components/sidebar/ProjectRow.tsx | 26 +++++++------------ .../sidebar/SidebarInlineRename.test.tsx | 2 +- .../sidebar/SidebarInlineRename.tsx | 15 +++++++++++ .../sidebar/SidebarRenameEditor.tsx | 11 ++------ .../components/sidebar/SidebarSectionRow.tsx | 10 ++++--- .../components/thread/ThreadActionsMenu.tsx | 20 +++++++++----- 8 files changed, 52 insertions(+), 41 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 7d591fd2fee..ad4a4f9a88b 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -1100,10 +1100,8 @@ function RenamableMachineSidebarSection({ labelEditor={rename.editor} onRename={rename.startEditing} actions={renderActions(props.id, props.label, { - onRename: rename.startEditing, - onCloseAutoFocus: (event) => { - if (rename.isEditing) event.preventDefault(); - }, + onRename: rename.startEditingFromMenu, + onCloseAutoFocus: rename.onCloseAutoFocus, })} /> ); diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index e4509491f6e..f12852eb86e 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -219,6 +219,9 @@ describe("ProjectRow interactions", () => { const input = await screen.findByRole("textbox", { name: "Project name" }); await waitFor(() => expect(document.activeElement).toBe(input)); fireEvent.change(input, { target: { value: " Renamed project " } }); + await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); + expect(document.activeElement).toBe(input); + expect(mockUpdateProject).not.toHaveBeenCalled(); fireEvent.keyDown(input, { key: "Enter" }); await waitFor(() => expect(mockUpdateProject).toHaveBeenCalledWith({ diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index d2b08d04498..ebba4f40e37 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -964,10 +964,8 @@ function EnvironmentThreadGroupHeader({ archiveThreadsPending={archiveThreadsPending} onArchiveThreads={onArchiveThreads} onCreateNewThread={onCreateNewThread} - onRenameEnvironment={rename.startEditing} - onCloseAutoFocus={(event) => { - if (rename.isEditing) event.preventDefault(); - }} + onRenameEnvironment={rename.startEditingFromMenu} + onCloseAutoFocus={rename.onCloseAutoFocus} onOpenChange={setIsActionsOpen} /> @@ -1460,12 +1458,10 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ : undefined } onOpenChange={setIsTopLevelActionsOpen} - onCloseAutoFocus={(event) => { - if (rename.isEditing) event.preventDefault(); - }} + onCloseAutoFocus={rename.onCloseAutoFocus} > onRemoveSection(section) : undefined } @@ -1517,7 +1513,9 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ label={section.name} labelEditor={rename.editor} onRename={rename.startEditing} + onRenameFromMenu={rename.startEditingFromMenu} depth={headerDepth} + onCloseAutoFocus={rename.onCloseAutoFocus} activity={section.activity} collapsedThreads={sectionThreads} consumeClickSuppression={consumeClickSuppression} @@ -2419,14 +2417,12 @@ function ProjectRowComponent({ showNewThread={!isLocalPathInvalid} onNewThread={onCreateProjectThread ? handleCreateThread : undefined} onOpenChange={setIsDropdownActionsOpen} - onCloseAutoFocus={(event) => { - if (rename.isEditing) event.preventDefault(); - }} + onCloseAutoFocus={rename.onCloseAutoFocus} > ); @@ -2435,10 +2431,8 @@ function ProjectRowComponent({ { - if (rename.isEditing) event.preventDefault(); - }} + onRename={rename.startEditingFromMenu} + onCloseAutoFocus={rename.onCloseAutoFocus} onOpenChange={setIsContextActionsOpen} >
{ const input = await screen.findByRole("textbox", { name: "first name", }); - expect(document.activeElement).toBe(input); + await waitFor(() => expect(document.activeElement).toBe(input)); expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe("Original name".length); fireEvent.change(input, { target: { value: "Discard me" } }); diff --git a/apps/app/src/components/sidebar/SidebarInlineRename.tsx b/apps/app/src/components/sidebar/SidebarInlineRename.tsx index a341dd10721..b23b7c2a6d5 100644 --- a/apps/app/src/components/sidebar/SidebarInlineRename.tsx +++ b/apps/app/src/components/sidebar/SidebarInlineRename.tsx @@ -10,6 +10,7 @@ import { useState, type ReactNode, } from "react"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; const loadRenameEditor = () => import("./SidebarRenameEditor"); const SidebarRenameEditor = lazy(loadRenameEditor); @@ -172,6 +173,8 @@ export function useSidebarRenameState() { } export function useSidebarRename(args: SidebarRenameArgs) { + const compact = useIsCompactViewport(); + const pendingMenuRename = useRef<(() => void) | null>(null); const shared = useContext(SidebarRenameContext); const local = useRenameController(); const controller = shared ?? local; @@ -215,5 +218,17 @@ export function useSidebarRename(args: SidebarRenameArgs) { isEditing, isPending: isEditing && session.isPending, startEditing, + startEditingFromMenu: () => { + if (compact) startEditing(); + else pendingMenuRename.current = startEditing; + }, + onCloseAutoFocus: (event: Event) => { + const begin = pendingMenuRename.current; + if (begin) { + pendingMenuRename.current = null; + event.preventDefault(); + begin(); + } + }, }; } diff --git a/apps/app/src/components/sidebar/SidebarRenameEditor.tsx b/apps/app/src/components/sidebar/SidebarRenameEditor.tsx index 4961f6bb2ed..c2186f66660 100644 --- a/apps/app/src/components/sidebar/SidebarRenameEditor.tsx +++ b/apps/app/src/components/sidebar/SidebarRenameEditor.tsx @@ -50,17 +50,10 @@ export default function SidebarRenameEditor({ const row = input?.closest("[data-sidebar-rename-row]"); anchorRef.current = row?.querySelector("[data-sidebar-rename-anchor]") ?? null; - input?.focus({ preventScroll: true }); - input?.select(); const frame = requestAnimationFrame(() => { + input?.focus({ preventScroll: true }); + input?.select(); openingRef.current = false; - if ( - document.activeElement === document.body || - row?.contains(document.activeElement) - ) { - input?.focus({ preventScroll: true }); - input?.select(); - } }); return () => cancelAnimationFrame(frame); }, []); diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.tsx index 09848de45d6..109547f0d61 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.tsx @@ -63,6 +63,8 @@ interface SidebarSectionRowProps { isDropTargetActive?: boolean; onCreateThread?: () => void; onRename?: () => void; + onRenameFromMenu?: () => void; + onCloseAutoFocus?: (event: Event) => void; onRemove?: () => void; } @@ -80,6 +82,8 @@ function SidebarSectionRowComponent({ onToggleCollapsed, onCreateThread, onRename, + onRenameFromMenu, + onCloseAutoFocus, onRemove, stickyLevel, }: SidebarSectionRowProps) { @@ -215,12 +219,10 @@ function SidebarSectionRowComponent({ label={`${label} section`} onNewThread={onCreateThread} onOpenChange={setIsActionsOpen} - onCloseAutoFocus={(event) => { - if (labelEditor) event.preventDefault(); - }} + onCloseAutoFocus={onCloseAutoFocus} > diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index c0f418a0340..d5016b7ac29 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -331,6 +331,7 @@ function useThreadActionsMenuLifecycle( onOpenChange?: (open: boolean) => void, onRename?: () => void, ) { + const compact = useIsCompactViewport(); const [compactStep, setCompactStep] = useState("actions"); const renameSelectedRef = useRef(false); @@ -349,13 +350,18 @@ function useThreadActionsMenuLifecycle( const handleRename = useCallback(() => { renameSelectedRef.current = true; - onRename?.(); - }, [onRename]); - const handleCloseAutoFocus = useCallback((event: Event) => { - if (renameSelectedRef.current) { - event.preventDefault(); - } - }, []); + if (compact) onRename?.(); + }, [compact, onRename]); + const handleCloseAutoFocus = useCallback( + (event: Event) => { + if (renameSelectedRef.current) { + renameSelectedRef.current = false; + event.preventDefault(); + if (!compact) onRename?.(); + } + }, + [compact, onRename], + ); return { compactStep, From 6bcb651065b135a3ad9616f6c5d6825a9baefce8 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:25:54 -0700 Subject: [PATCH 06/20] Preserve sidebar rename focus when opening a thread --- .../promptbox/PromptBoxInternal.test.tsx | 28 +++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index f67b8d364cd..721287806d4 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -984,6 +984,34 @@ describe("PromptBoxInternal controlled value sync", () => { } }); + it("preserves an active sidebar rename when the thread focus scope changes", async () => { + const restoreMatchMedia = mockPointerCoarse(false); + const rename = document.createElement("span"); + rename.setAttribute("data-sidebar-rename-editor", ""); + const input = document.createElement("input"); + rename.append(input); + document.body.append(rename); + try { + const props = createPromptBoxProps({ focusScopeKey: "first-thread" }); + const view = render(); + await waitForPromptFocus(); + input.focus(); + view.rerender( + , + ); + await act( + () => + new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ), + ); + expect(document.activeElement).toBe(input); + } finally { + rename.remove(); + restoreMatchMedia(); + } + }); + it("releases passive editor focus when autofocus becomes blocked", async () => { const restoreMatchMedia = mockPointerCoarse(false); try { diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 5ba90fd906f..1b15cea806c 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -333,7 +333,10 @@ function PromptSubmitButton({ )} > {isBusy ? ( - + ) : ( <> @@ -1957,6 +1960,8 @@ export function PromptBoxInternal({ const focusEditor = () => { if (editor.isDestroyed) return; + if (document.activeElement?.closest("[data-sidebar-rename-editor]")) + return; focusEditorAtEnd(editor); scheduleRevealEditorSelection(); }; From fc9a0efe6bd9783807eaedf5575732fef4070c11 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:29:16 -0700 Subject: [PATCH 07/20] Exercise composer autofocus with the supported test props --- .../components/promptbox/PromptBoxInternal.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 721287806d4..c72a9043b14 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -984,7 +984,7 @@ describe("PromptBoxInternal controlled value sync", () => { } }); - it("preserves an active sidebar rename when the thread focus scope changes", async () => { + it("preserves an active sidebar rename when composer autofocus starts", async () => { const restoreMatchMedia = mockPointerCoarse(false); const rename = document.createElement("span"); rename.setAttribute("data-sidebar-rename-editor", ""); @@ -992,13 +992,13 @@ describe("PromptBoxInternal controlled value sync", () => { rename.append(input); document.body.append(rename); try { - const props = createPromptBoxProps({ focusScopeKey: "first-thread" }); + const props = createPromptBoxProps({ autoFocus: false }); const view = render(); - await waitForPromptFocus(); - input.focus(); - view.rerender( - , + await waitFor(() => + expect(getPromptEditorElement()).toBeInstanceOf(HTMLElement), ); + input.focus(); + view.rerender(); await act( () => new Promise((resolve) => From 4291b09aa1ddf99a28dd4bae1ed7a244b3c3f207 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:30:19 -0700 Subject: [PATCH 08/20] Include environment headings in sidebar rename stories --- apps/app/src/components/sidebar/SidebarOverview.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx index 301b85e4f7f..3c4644aa18e 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -193,6 +193,7 @@ const loadedSidebarNavigation = makeSidebarBootstrapResponse({ environmentId: "env_story_sidebar", environmentName: "Sidebar polish", environmentBranchName: BRANCH_NAMES.feature, + environmentIsWorktree: true, environmentProviderId: "git-worktree", queuedWork: "none", title: "Tighten loading skeleton", @@ -207,6 +208,7 @@ const loadedSidebarNavigation = makeSidebarBootstrapResponse({ environmentId: "env_story_sidebar", environmentName: "Sidebar polish", environmentBranchName: BRANCH_NAMES.feature, + environmentIsWorktree: true, environmentProviderId: "git-worktree", queuedWork: "none", title: "Audit sidebar stories", From 4328609d07de1cf6505603abf70141ba75acd003 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 16:36:25 -0700 Subject: [PATCH 09/20] Keep machine rename controls accessible while dragging is disabled --- apps/app/src/components/sidebar/BuiltInSidebarSection.tsx | 2 +- apps/app/src/components/sidebar/ProjectList.modes.test.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx b/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx index b6f44b0a296..ea4cef8ad60 100644 --- a/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx +++ b/apps/app/src/components/sidebar/BuiltInSidebarSection.tsx @@ -63,7 +63,7 @@ export const SortableSidebarSection = memo(function SortableSidebarSection({ return ( diff --git a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx index aed91a31d40..c14bfe392f9 100644 --- a/apps/app/src/components/sidebar/ProjectList.modes.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.modes.test.tsx @@ -305,6 +305,7 @@ describe("sidebar organization mode sections", () => { fireEvent.doubleClick(screen.getByTitle("Work laptop")); const input = await screen.findByRole("textbox", { name: "Machine name" }); + expect(input.closest('[aria-disabled="true"]')).toBeNull(); fireEvent.change(input, { target: { value: "Studio" } }); fireEvent.keyDown(input, { key: "Enter" }); await waitFor(() => From 926d6ef472563f59831cc66e7fd74e884f464192 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 17:04:12 -0700 Subject: [PATCH 10/20] Align sidebar rename actions to the row edge --- apps/app/src/components/sidebar/ProjectRow.tsx | 8 +++++++- apps/app/src/components/sidebar/SidebarSectionRow.tsx | 3 ++- apps/app/src/components/sidebar/ThreadRow.tsx | 9 ++++++++- .../src/components/sidebar/TopLevelSidebarSection.tsx | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index ebba4f40e37..a75852b4912 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -927,6 +927,7 @@ function EnvironmentThreadGroupHeader({ )} - + {showRollupGlyph ? ( {hasActions && showRollupIndicator ? ( diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 5e79fa677cd..a68cf7d2222 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -507,6 +507,7 @@ function ThreadRowComponent({ className={cn( "flex min-w-0 flex-1 items-center gap-1.5", !shortcut && + !isEditing && (parentOptions && hasChildren ? "pr-7.5 max-md:pointer-coarse:pr-0" : SIDEBAR_HOVER_ACTIONS_INSET_CLASS), @@ -528,6 +529,7 @@ function ThreadRowComponent({ {parentOptions && hasChildren ? ( ) : null} - + {shortcut ? ( ) : ( diff --git a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx index edb0908d8e7..89ad4d08336 100644 --- a/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx +++ b/apps/app/src/components/sidebar/TopLevelSidebarSection.tsx @@ -234,6 +234,7 @@ export function TopLevelSidebarSection({ "relative z-20 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md outline-none ring-sidebar-ring focus-visible:ring-2", SIDEBAR_CONTROL_STATE_CLASS, LIST_HOVER_TRANSITION, + labelEditor && "hidden", )} onClick={handleCollapseControlClick} onPointerDown={stopCollapseControlPointerDown} From 893a2370f488520beb2ba0a640b4554b3aaf8cba Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 18:48:56 -0700 Subject: [PATCH 11/20] Show section rename in the production sidebar story --- apps/app/.ladle/sidebar-rename-fixtures.ts | 8 ++ .../sidebar/SidebarOverview.stories.tsx | 78 +++++++++++-------- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/apps/app/.ladle/sidebar-rename-fixtures.ts b/apps/app/.ladle/sidebar-rename-fixtures.ts index aee5cc87b19..18ec829f39d 100644 --- a/apps/app/.ladle/sidebar-rename-fixtures.ts +++ b/apps/app/.ladle/sidebar-rename-fixtures.ts @@ -31,6 +31,14 @@ export function installSidebarRenameStoryApi({ if (request.method === "GET" && path === "/api/v1/hosts") { return Response.json(hosts); } + if ( + request.method === "GET" && + hosts.some( + (host) => path === `/api/v1/hosts/${host.id}/provider-clis/status`, + ) + ) { + return Response.json({}); + } const projects = [...navigation.projects, navigation.personalProject]; const threads = projects.flatMap((project) => project.threads); const id = decodeURIComponent(path.split("/").at(-1) ?? ""); diff --git a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx index 3c4644aa18e..9228bc546b5 100644 --- a/apps/app/src/components/sidebar/SidebarOverview.stories.tsx +++ b/apps/app/src/components/sidebar/SidebarOverview.stories.tsx @@ -25,6 +25,9 @@ import { } from "../../../.ladle/story-fixtures"; import { ProjectActionsProvider } from "@/components/project/ProjectActionsProvider"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; +import { QuickCreateProjectProvider } from "@/hooks/useQuickCreateProject"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "./AppSidebar"; import { Icon } from "@bb/shared-ui/icon"; import { ProjectList, @@ -291,7 +294,6 @@ const renameSidebarNavigation: SidebarBootstrapResponse = { }; function RenameSidebar() { - const [mode, setMode] = useState("project"); const [ready, setReady] = useState(false); const [failNext, setFailNext] = useState(false); const failureRef = useRef(false); @@ -312,27 +314,6 @@ function RenameSidebar() { return (
-
); return context ? ( {row} @@ -154,23 +159,27 @@ describe("ThreadActionsMenu", () => { }); }); - it.each([false, true])( - "hands keyboard focus from the wide menu to inline rename (context: %s)", - async (context) => { - renderWide(); + it.each([ + { compact: false, context: false }, + { compact: false, context: true }, + { compact: true, context: false }, + { compact: true, context: true }, + ])( + "hands focus to inline rename ($compact, $context)", + async ({ compact, context }) => { + (compact ? renderCompact : renderWide)( + , + ); if (context) { fireEvent.contextMenu(screen.getByTestId("thread-row")); } else { - fireEvent.pointerDown( - screen.getByRole("button", { name: "Thread actions" }), - { button: 0 }, - ); + const trigger = screen.getByRole("button", { name: "Thread actions" }); + if (compact) fireEvent.click(trigger); + else fireEvent.pointerDown(trigger, { button: 0 }); } - - fireEvent.keyDown(screen.getByRole("menuitem", { name: "Rename" }), { - key: "Enter", - }); - + const item = await screen.findByRole("menuitem", { name: "Rename" }); + if (compact) fireEvent.click(item); + else fireEvent.keyDown(item, { key: "Enter" }); const input = await screen.findByRole("textbox", { name: "Thread name" }); await waitFor(() => expect(document.activeElement).toBe(input)); expect(input).toHaveProperty("value", "Move me"); @@ -178,24 +187,6 @@ describe("ThreadActionsMenu", () => { }, ); - it.each([false, true])( - "hands focus from the compact drawer to inline rename (context: %s)", - async (context) => { - renderCompact(); - if (context) { - fireEvent.contextMenu(screen.getByTestId("thread-row")); - } else { - fireEvent.click(screen.getByRole("button", { name: "Thread actions" })); - } - - fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); - - const input = await screen.findByRole("textbox", { name: "Thread name" }); - await waitFor(() => expect(document.activeElement).toBe(input)); - expect(threadActions.requestRename).not.toHaveBeenCalled(); - }, - ); - it("copies the canonical thread URL from every menu instance", () => { renderWide(); diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx index d5016b7ac29..bb615554e3d 100644 --- a/apps/app/src/components/thread/ThreadActionsMenu.tsx +++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx @@ -3,7 +3,7 @@ import { ActionMenuSeparator, } from "@/components/ui/action-menu-items"; import type { Thread } from "@bb/domain"; -import { useCallback, useRef, useState, type ReactNode } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { ContextMenu, ContextMenuContent, @@ -41,6 +41,7 @@ interface ThreadActionsMenuBaseProps { thread: Thread; onOpenInSplit?: () => void; onRename?: () => void; + onCloseAutoFocus?: (event: Event) => void; } export interface ThreadActionsMenuResponsiveAction { @@ -327,19 +328,11 @@ function ThreadActionsMenuItems({ ); } -function useThreadActionsMenuLifecycle( - onOpenChange?: (open: boolean) => void, - onRename?: () => void, -) { - const compact = useIsCompactViewport(); +function useThreadActionsMenuLifecycle(onOpenChange?: (open: boolean) => void) { const [compactStep, setCompactStep] = useState("actions"); - const renameSelectedRef = useRef(false); const handleOpenChange = useCallback( (open: boolean) => { - if (open) { - renameSelectedRef.current = false; - } if (!open) { setCompactStep("actions"); } @@ -348,28 +341,7 @@ function useThreadActionsMenuLifecycle( [onOpenChange], ); - const handleRename = useCallback(() => { - renameSelectedRef.current = true; - if (compact) onRename?.(); - }, [compact, onRename]); - const handleCloseAutoFocus = useCallback( - (event: Event) => { - if (renameSelectedRef.current) { - renameSelectedRef.current = false; - event.preventDefault(); - if (!compact) onRename?.(); - } - }, - [compact, onRename], - ); - - return { - compactStep, - setCompactStep, - handleOpenChange, - handleCloseAutoFocus, - handleRename: onRename ? handleRename : undefined, - }; + return { compactStep, setCompactStep, handleOpenChange }; } export function ThreadArchiveQuickAction({ @@ -416,17 +388,13 @@ export function ThreadActionsMenu({ thread, onOpenInSplit, onRename, + onCloseAutoFocus, responsiveActions, onOpenChange, triggerClassName, }: ThreadActionsMenuProps) { - const { - compactStep, - setCompactStep, - handleOpenChange, - handleCloseAutoFocus, - handleRename, - } = useThreadActionsMenuLifecycle(onOpenChange, onRename); + const { compactStep, setCompactStep, handleOpenChange } = + useThreadActionsMenuLifecycle(onOpenChange); return ( @@ -451,11 +419,11 @@ export function ThreadActionsMenu({ /> - + + {children} From 6102e1a471171161863eb162a8ff1095a7ece772 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 23:13:22 -0700 Subject: [PATCH 20/20] Drop duplicate rename test using a synthetic menu --- .../src/components/sidebar/ThreadRow.test.tsx | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index e6377867270..7c83c5dd9dd 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -58,9 +58,7 @@ vi.mock("@/components/thread/ThreadActionsMenu", () => ({ ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => ( <>{children} ), - ThreadActionsMenu: ({ onRename }: { onRename?: () => void }) => ( -