diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 8d23212bff..b9eb4b3407 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -68,6 +68,18 @@ vi.mock("@/components/project/ProjectActionsProvider", () => ({ }), })); +vi.mock("@/components/thread/ThreadActionsProvider", () => ({ + useThreadActions: () => ({ + renameThread: vi.fn(), + requestRename: vi.fn(), + requestDelete: vi.fn(), + archiveThreadAndChildren: vi.fn(), + unarchiveThread: vi.fn(), + togglePin: vi.fn(), + toggleRead: vi.fn(), + }), +})); + function makeProject(): ProjectResponse { return { id: "proj_test", diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx index 76d3652e28..9366bb2dac 100644 --- a/apps/app/src/components/sidebar/ThreadRow.test.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -1,13 +1,27 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; import { createStore, Provider } from "jotai"; import type { ThreadListEntry } from "@bb/domain"; import type { PluginComposerThreadRowStatus } from "@bb/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ThreadRow, type ThreadRowOptions } from "./ThreadRow"; +import { + resetSidebarTitleDoubleClickForTest, + ThreadRow, + type ThreadRowOptions, +} from "./ThreadRow"; + +const mocks = vi.hoisted(() => ({ + renameThread: vi.fn(), +})); + +vi.mock("@/components/thread/ThreadActionsProvider", () => ({ + useThreadActions: () => ({ + renameThread: mocks.renameThread, + }), +})); import { SidebarThreadTitleMentionResourcesProvider } from "./SidebarThreadTitleMentions"; import { SIDEBAR_ROW_OPEN_IN_SPLIT_STATE_CLASS, @@ -225,6 +239,8 @@ function renderSplitThreadRow({ afterEach(() => { cleanup(); + mocks.renameThread.mockReset(); + resetSidebarTitleDoubleClickForTest(); resetPluginThreadRowStatusesForTest(); // The layout is tab-scoped, so it lands in both stores (createTabScopedStorage). window.localStorage.removeItem(SPLIT_LAYOUT_STORAGE_KEY); @@ -1257,4 +1273,51 @@ describe("ThreadRow", () => { expect(container.querySelector('[data-icon="CircleCheck"]')).toBeNull(); expect(screen.getByLabelText("Unread thread succeeded")).not.toBeNull(); }); + + it("edits the row title inline after a double click and commits on Enter", () => { + renderThreadRow({ + thread: createThread({ title: "Thread", titleFallback: "Thread" }), + }); + + fireEvent.doubleClick(screen.getByText("Thread")); + const input = screen.getByRole("textbox", { name: "Thread name" }); + expect(input).toHaveProperty("value", "Thread"); + + 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(); + expect(screen.getByText("Thread")).not.toBeNull(); + }); + + it("cancels an inline row rename on Escape without saving", () => { + renderThreadRow({ + thread: createThread({ title: "Thread", titleFallback: "Thread" }), + }); + + fireEvent.doubleClick(screen.getByText("Thread")); + const input = screen.getByRole("textbox", { name: "Thread name" }); + fireEvent.change(input, { target: { value: "Scratch name" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(mocks.renameThread).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + expect(screen.getByText("Thread")).not.toBeNull(); + }); + + it("starts a rename from a second click after the row remounts", () => { + const thread = createThread({ title: "Thread", titleFallback: "Thread" }); + const { rerenderThreadRow } = renderThreadRow({ thread }); + const link = screen.getByRole("link", { name: "Open Thread" }); + + fireEvent.click(link); + rerenderThreadRow(thread); + fireEvent.click(screen.getByRole("link", { name: "Open Thread" })); + + expect(screen.getByRole("textbox", { name: "Thread name" })).toHaveProperty( + "value", + "Thread", + ); + }); }); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 503f5f64cd..5522be9d45 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -18,6 +18,8 @@ import { ThreadActionsContextMenu, ThreadActionsMenu, } from "@/components/thread/ThreadActionsMenu"; +import { useThreadActions } from "@/components/thread/ThreadActionsProvider"; +import { useInlineThreadTitle } from "@/components/thread/InlineThreadTitle"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -75,6 +77,25 @@ import { useThreadTitleDisplayText } from "@/components/thread/ThreadTitleMentio import { pluginIconName } from "@/components/plugin/PluginIcon"; import { usePluginThreadRowStatus } from "@/lib/plugin-thread-row-status"; +const SIDEBAR_TITLE_DOUBLE_CLICK_MS = 400; + +let lastSidebarTitleClick: { at: number; threadId: string } | null = null; + +function consumeSidebarTitleDoubleClick(threadId: string): boolean { + const now = Date.now(); + const previous = lastSidebarTitleClick; + lastSidebarTitleClick = { at: now, threadId }; + return ( + previous !== null && + previous.threadId === threadId && + now - previous.at < SIDEBAR_TITLE_DOUBLE_CLICK_MS + ); +} + +export function resetSidebarTitleDoubleClickForTest(): void { + lastSidebarTitleClick = null; +} + interface ThreadRowBaseOptions { depth: number; isCompact: boolean; @@ -480,6 +501,7 @@ function ThreadRowComponent({ }: ThreadRowProps) { const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false); const [isContextActionsOpen, setIsContextActionsOpen] = useState(false); + const { renameThread } = useThreadActions(); const setConversationCollapsed = useSetAtom( getThreadConversationCollapsedAtom(thread.id), ); @@ -501,6 +523,25 @@ function ThreadRowComponent({ // Inside a section the row shows the leaf but keeps the full path for a11y. const visibleTitle = displayTitle ?? threadTitle; const labelTitle = useThreadTitleDisplayText(accessibleTitle ?? threadTitle); + const handleRename = useCallback( + (nextTitle: string) => { + renameThread(thread.id, nextTitle); + }, + [renameThread, thread.id], + ); + const { editor, isEditing, startEditing } = useInlineThreadTitle({ + onCommit: handleRename, + resetKey: thread.id, + title: threadTitle, + }); + const startTitleEditing = useCallback( + (event: { preventDefault: () => void; stopPropagation: () => void }) => { + event.preventDefault(); + event.stopPropagation(); + startEditing(); + }, + [startEditing], + ); const threadSplitsEnabled = useThreadSplitsEnabled(); const splitIndicator = usePaneContentSplitIndicator( { kind: "thread", projectId, threadId: thread.id }, @@ -625,6 +666,11 @@ function ThreadRowComponent({ data-sidebar-thread-shortcut-target="" data-sidebar-thread-id={thread.id} onClick={(event) => { + if (isEditing) { + event.preventDefault(); + event.stopPropagation(); + return; + } // Selecting a thread/agent row restores its conversation without // disturbing any other thread's collapsed conversation state. setConversationCollapsed(false); @@ -636,16 +682,36 @@ function ThreadRowComponent({ openInSplit(); return; } + // A first click may navigate and remount this row. Remember that + // click so the second click of a double-click can still open the + // editor after the remount. + if (consumeSidebarTitleDoubleClick(thread.id)) { + event.preventDefault(); + event.stopPropagation(); + startEditing(); + return; + } onProjectSelect?.(); }} + onDoubleClick={isEditing ? undefined : startTitleEditing} aria-label={linkLabel} aria-keyshortcuts={shortcut?.ariaKeyshortcuts} className="absolute inset-0 rounded-md outline-none ring-sidebar-ring focus-visible:ring-2" /> - - - + {isEditing ? ( + + {editor} + + ) : ( + + + + )} {parentOptions && hasChildren ? ( { + cleanup(); +}); + +function InlineTitleHarness({ + onCommit, + resetKey = "thr_test", + title, +}: { + onCommit: (nextTitle: string) => void; + resetKey?: string; + title: string; +}) { + const { editor, isEditing, startEditing } = useInlineThreadTitle({ + onCommit, + resetKey, + title, + }); + + return ( +
+ {isEditing ? ( + editor + ) : ( + + )} +
+ ); +} + +describe("resolveInlineThreadTitleCommit", () => { + it("commits a trimmed new title", () => { + expect( + resolveInlineThreadTitleCommit({ + currentTitle: "Old name", + nextTitle: " New name ", + }), + ).toEqual({ kind: "commit", title: "New name" }); + }); + + it("cancels an empty or unchanged title", () => { + expect( + resolveInlineThreadTitleCommit({ + currentTitle: "Same name", + nextTitle: " ", + }), + ).toEqual({ kind: "cancel" }); + expect( + resolveInlineThreadTitleCommit({ + currentTitle: "Same name", + nextTitle: " Same name ", + }), + ).toEqual({ kind: "cancel" }); + }); +}); + +describe("useInlineThreadTitle", () => { + it("commits a changed title on blur and ignores a second close", () => { + const onCommit = vi.fn(); + render(); + + fireEvent.doubleClick(screen.getByRole("button", { name: "Old name" })); + const input = screen.getByRole("textbox", { name: "Thread name" }); + fireEvent.change(input, { target: { value: "New name" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith("New name"); + }); + + it("does not commit when the draft is empty", () => { + const onCommit = vi.fn(); + render(); + + fireEvent.doubleClick(screen.getByRole("button", { name: "Old name" })); + const input = screen.getByRole("textbox", { name: "Thread name" }); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Old name" })).not.toBeNull(); + }); + + it("cancels an open edit when the thread identity changes", () => { + const firstCommit = vi.fn(); + const secondCommit = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.doubleClick(screen.getByRole("button", { name: "Old name" })); + fireEvent.change(screen.getByRole("textbox", { name: "Thread name" }), { + target: { value: "Draft name" }, + }); + + rerender( + , + ); + + expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + expect(screen.getByRole("button", { name: "Other thread" })).not.toBeNull(); + expect(firstCommit).not.toHaveBeenCalled(); + expect(secondCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/components/thread/InlineThreadTitle.tsx b/apps/app/src/components/thread/InlineThreadTitle.tsx new file mode 100644 index 0000000000..4af9e88eaf --- /dev/null +++ b/apps/app/src/components/thread/InlineThreadTitle.tsx @@ -0,0 +1,196 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { cn } from "@bb/shared-ui/lib/utils"; + +export interface InlineThreadTitleCommitResult { + kind: "cancel" | "commit"; + title?: string; +} + +export function resolveInlineThreadTitleCommit(args: { + currentTitle: string; + nextTitle: string; +}): InlineThreadTitleCommitResult { + const title = args.nextTitle.trim(); + if (title.length === 0 || title === args.currentTitle) { + return { kind: "cancel" }; + } + return { kind: "commit", title }; +} + +interface InlineThreadTitleEditorProps { + ariaLabel: string; + className?: string; + value: string; + onCancel: () => void; + onChange: (value: string) => void; + onSubmit: () => void; +} + +export function InlineThreadTitleEditor({ + ariaLabel, + className, + value, + onCancel, + onChange, + onSubmit, +}: InlineThreadTitleEditorProps) { + const inputRef = useRef(null); + const closedRef = useRef(false); + + useEffect(() => { + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + input.select(); + }, []); + + const closeOnce = useCallback((action: () => void) => { + if (closedRef.current) { + return; + } + closedRef.current = true; + action(); + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + closeOnce(onSubmit); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeOnce(onCancel); + } + }; + + return ( + { + closeOnce(onSubmit); + }} + onChange={(event) => { + onChange(event.target.value); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onKeyDown={handleKeyDown} + onPointerDown={(event) => { + event.stopPropagation(); + }} + /> + ); +} + +interface UseInlineThreadTitleArgs { + inputClassName?: string; + onCommit: (title: string) => void; + /** Cancel an open edit when this identity changes (usually the thread id). */ + resetKey: string; + title: string; +} + +interface UseInlineThreadTitleResult { + editor: ReactNode; + isEditing: boolean; + startEditing: () => void; +} + +export function useInlineThreadTitle({ + inputClassName, + onCommit, + resetKey, + title, +}: UseInlineThreadTitleArgs): UseInlineThreadTitleResult { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(title); + const titleAtStartRef = useRef(title); + const onCommitAtStartRef = useRef(onCommit); + const resetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKeyRef.current !== resetKey) { + resetKeyRef.current = resetKey; + setIsEditing(false); + setDraft(title); + return; + } + if (!isEditing) { + setDraft(title); + } + }, [isEditing, resetKey, title]); + + const startEditing = useCallback(() => { + titleAtStartRef.current = title; + onCommitAtStartRef.current = onCommit; + resetKeyRef.current = resetKey; + setDraft(title); + setIsEditing(true); + }, [onCommit, resetKey, title]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + setDraft(titleAtStartRef.current); + }, []); + + const submitEditing = useCallback(() => { + const result = resolveInlineThreadTitleCommit({ + currentTitle: titleAtStartRef.current, + nextTitle: draft, + }); + setIsEditing(false); + setDraft(titleAtStartRef.current); + if (result.kind === "commit" && result.title !== undefined) { + onCommitAtStartRef.current(result.title); + } + }, [draft]); + + return { + editor: isEditing ? ( + + ) : null, + isEditing, + startEditing, + }; +} diff --git a/apps/app/src/components/thread/ThreadActionsProvider.tsx b/apps/app/src/components/thread/ThreadActionsProvider.tsx index 1615422d80..87cca750d5 100644 --- a/apps/app/src/components/thread/ThreadActionsProvider.tsx +++ b/apps/app/src/components/thread/ThreadActionsProvider.tsx @@ -50,6 +50,7 @@ import { getDesktopBrowserApi } from "@/lib/bb-desktop"; export interface ThreadActionsContextValue { archiveThreadAndChildren: (thread: Thread) => void; + renameThread: (threadId: string, title: string) => void; requestRename: (thread: Thread) => void; requestDelete: (thread: Thread) => void; unarchiveThread: (thread: Thread) => void; @@ -180,6 +181,13 @@ export function ThreadActionsProvider({ [openRenameDialog], ); + const renameThread = useCallback( + (threadId: string, title: string) => { + updateMutate({ id: threadId, title }); + }, + [updateMutate], + ); + const submitRename = useCallback( (threadId: string, payload: ThreadRenameDialogPayload) => { updateMutate( @@ -427,6 +435,7 @@ export function ThreadActionsProvider({ const value = useMemo( () => ({ + renameThread, requestRename, requestDelete, archiveThreadAndChildren: archiveThreadAndChildrenAction, @@ -436,6 +445,7 @@ export function ThreadActionsProvider({ }), [ archiveThreadAndChildrenAction, + renameThread, requestRename, requestDelete, togglePin, diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx index 8a46cf2304..ae0b8e467a 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { createStore, Provider as JotaiProvider } from "jotai"; import type { ReactNode, Ref } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -11,6 +17,16 @@ import { ThreadTitleMentionResourcesProvider } from "@/components/thread/ThreadT import { makeThreadListEntry } from "@/test/fixtures/thread-list-entries"; import { sdk } from "@/lib/sdk"; +const mocks = vi.hoisted(() => ({ + renameThread: vi.fn(), +})); + +vi.mock("@/components/thread/ThreadActionsProvider", () => ({ + useThreadActions: () => ({ + renameThread: mocks.renameThread, + }), +})); + vi.mock("@/components/layout/AppPageHeader", () => ({ HEADER_ICON_BUTTON_CLASS: "header-icon-button", HEADER_PANE_ACTION_ICON_BUTTON_CLASS: "header-pane-action-button", @@ -40,6 +56,8 @@ vi.mock("./SplitDimmingButton", () => ({ SplitDimmingButton: () => null, })); +const THREAD_ID = "thr_header"; + const PANE_CONTEXT: PaneContextValue = { paneId: "main", isFocused: true, @@ -57,6 +75,7 @@ const PANE_CONTEXT: PaneContextValue = { afterEach(() => { cleanup(); + mocks.renameThread.mockReset(); vi.restoreAllMocks(); window.localStorage.clear(); }); @@ -75,6 +94,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Panel state" /> , @@ -102,6 +122,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Split panel state" /> , @@ -151,6 +172,7 @@ describe("ThreadDetailHeader", () => { threadHeaderGitActions={[ { label: "Commit", target: { kind: "commit" } }, ]} + threadId={THREAD_ID} threadTitle="Narrow split" workspaceOpenButton={} /> @@ -209,6 +231,7 @@ describe("ThreadDetailHeader", () => { threadHeaderGitActions={[ { label: "Commit", target: { kind: "commit" } }, ]} + threadId={THREAD_ID} threadTitle="Wide split" workspaceOpenButton={} /> @@ -231,6 +254,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Review @docs/foo.test.ts with @thread:thr_worker" /> , @@ -266,6 +290,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Continue from thr_dcwivn5n8w docs/foo.ts" /> @@ -306,6 +331,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle={title} /> @@ -353,6 +379,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle={title} /> @@ -382,6 +409,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Unknown thr_2222222222" /> @@ -409,6 +437,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Focused thread" /> , @@ -435,6 +464,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Focused thread" /> , @@ -466,6 +496,7 @@ describe("ThreadDetailHeader", () => { onOpenThreadGitAction={vi.fn()} onToggleSecondaryPanel={vi.fn()} threadHeaderGitActions={[]} + threadId={THREAD_ID} threadTitle="Inactive thread" /> @@ -475,4 +506,88 @@ describe("ThreadDetailHeader", () => { "text-muted-foreground/60", ); }); + + it("edits the title inline after a double click and commits on Enter", () => { + render( + + + , + ); + + fireEvent.doubleClick(screen.getByText("Focused thread")); + const input = screen.getByRole("textbox", { name: "Thread name" }); + expect(input).toHaveProperty("value", "Focused thread"); + + fireEvent.change(input, { target: { value: "Renamed thread" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mocks.renameThread).toHaveBeenCalledWith(THREAD_ID, "Renamed thread"); + expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + expect(screen.getByText("Focused thread")).not.toBeNull(); + }); + + it("cancels an inline header rename on Escape without saving", () => { + render( + + + , + ); + + fireEvent.doubleClick(screen.getByText("Focused thread")); + const input = screen.getByRole("textbox", { name: "Thread name" }); + fireEvent.change(input, { target: { value: "Scratch name" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(mocks.renameThread).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox", { name: "Thread name" })).toBeNull(); + expect(screen.getByText("Focused thread")).not.toBeNull(); + }); + + it("does not start a pane drag while the header title is being edited", () => { + const beginPaneDrag = vi.fn(); + render( + + + , + ); + + fireEvent.doubleClick(screen.getByText("Focused thread")); + const input = screen.getByRole("textbox", { name: "Thread name" }); + fireEvent.pointerDown(input, { button: 0 }); + + expect(beginPaneDrag).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx index bc2b9a3a9d..8805a563fb 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx @@ -1,4 +1,5 @@ import { + useCallback, useContext, useLayoutEffect, useRef, @@ -27,6 +28,8 @@ import { import { cn } from "@bb/shared-ui/lib/utils"; import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint"; +import { useInlineThreadTitle } from "@/components/thread/InlineThreadTitle"; +import { useThreadActions } from "@/components/thread/ThreadActionsProvider"; import { ThreadTitleMentions } from "@/components/thread/ThreadTitleMentions"; import { SecondaryPanelHostLayoutContext } from "@/components/secondary-panel/SecondaryPanelHostLayoutContext"; import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens"; @@ -66,6 +69,7 @@ interface ThreadDetailHeaderProps { /** Plugin-contributed thread action buttons (design ยง4.9); optional. */ pluginActions?: ReactNode; threadHeaderGitActions: ThreadHeaderGitAction[]; + threadId: string; threadTitle: string; workspaceOpenButton?: ReactNode; } @@ -79,10 +83,23 @@ export function ThreadDetailHeader({ onToggleSecondaryPanel, pluginActions, threadHeaderGitActions, + threadId, threadTitle, workspaceOpenButton, }: ThreadDetailHeaderProps) { const [primaryAction, ...secondaryActions] = threadHeaderGitActions; + const { renameThread } = useThreadActions(); + const handleRename = useCallback( + (nextTitle: string) => { + renameThread(threadId, nextTitle); + }, + [renameThread, threadId], + ); + const { editor, isEditing, startEditing } = useInlineThreadTitle({ + onCommit: handleRename, + resetKey: threadId, + title: threadTitle, + }); const renderAsDrawer = useIsCompactViewport(); const [desktopInfo] = useState(getBbDesktopInfo); const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); @@ -129,11 +146,17 @@ export function ThreadDetailHeader({ }; }, [isSplitPaneHeader]); const handleTitlePointerDown = (event: ReactPointerEvent) => { - if (!beginPaneDrag || event.button !== 0) { + if (isEditing || !beginPaneDrag || event.button !== 0) { return; } beginPaneDrag(event, threadTitle); }; + const handleTitleDoubleClick = () => { + if (isEditing) { + return; + } + startEditing(); + }; const rightPanelLabel = isSecondaryPanelOpen ? "Hide right panel" : "Show right panel"; @@ -158,12 +181,14 @@ export function ThreadDetailHeader({ >

- + {isEditing ? editor : }

{childPillLabel ? ( diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 938f37c750..e8c259cc53 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -2570,6 +2570,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { /> } threadHeaderGitActions={gitActions.threadHeaderGitActions} + threadId={thread.id} threadTitle={threadTitle} workspaceOpenButton={workspaceOpenButton} />