diff --git a/dashboard/src/v2/ChatPage.tsx b/dashboard/src/v2/ChatPage.tsx index 389f7b8e79..43fbde6f78 100644 --- a/dashboard/src/v2/ChatPage.tsx +++ b/dashboard/src/v2/ChatPage.tsx @@ -113,6 +113,7 @@ export const ChatPage: FunctionComponent = () => { handleSend, navigateHistory, handleDeleteThread, + handleRenameThread, createThreadForCompose, threadIndex, invocationIndex, @@ -366,6 +367,7 @@ export const ChatPage: FunctionComponent = () => { thread={selectedThread} onCompact={() => void handleCompactThread()} onCancelActiveTurn={() => void handleCancelActiveTurn()} + onRename={handleRenameThread} isCompacting={compacting} isCancelling={isCancelling} /> diff --git a/dashboard/src/v2/components/chat/ChatThreadHeader.tsx b/dashboard/src/v2/components/chat/ChatThreadHeader.tsx index 0ffe58f567..620531f856 100644 --- a/dashboard/src/v2/components/chat/ChatThreadHeader.tsx +++ b/dashboard/src/v2/components/chat/ChatThreadHeader.tsx @@ -1,5 +1,6 @@ import { h, type FunctionComponent } from "preact"; -import { AlertCircle, Zap, Activity, XCircle } from "lucide-preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { AlertCircle, Zap, Activity, XCircle, PencilLine, Check, X, RefreshCw } from "lucide-preact"; import type { ChatThread } from "../../types.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; @@ -32,6 +33,7 @@ interface ChatThreadHeaderProps { thread: ChatThread | null; onCompact: () => void; onCancelActiveTurn: () => void; + onRename: (title: string) => Promise; isCompacting: boolean; isCancelling: boolean; } @@ -40,20 +42,76 @@ export const ChatThreadHeader: FunctionComponent = ({ thread, onCompact, onCancelActiveTurn, + onRename, isCompacting, isCancelling, }) => { const assignedLabel = resolveAssignedLabel(thread); const interactionTokens = useInteractionTokens(); + const inputRef = useRef(null); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(thread?.title || ""); + const [renamePending, setRenamePending] = useState(false); + const [renameError, setRenameError] = useState(null); const isReplayRequired = thread?.runtimeState?.replayRequired; const hasActiveSession = thread?.runtimeState?.sessionIds && thread.runtimeState.sessionIds.length > 0; const isNewOrCompacted = !hasActiveSession || isReplayRequired; + const titleErrorId = thread ? `thread-title-error-${thread.id}` : undefined; + + useEffect(() => { + if (!isEditingTitle) { + setTitleDraft(thread?.title || ""); + setRenameError(null); + } + }, [isEditingTitle, thread?.id, thread?.title]); + + useEffect(() => { + if (isEditingTitle) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditingTitle]); + + const cancelRename = (): void => { + setTitleDraft(thread?.title || ""); + setRenameError(null); + setIsEditingTitle(false); + }; + + const saveRename = async (): Promise => { + if (!thread || renamePending) { + return; + } + + const trimmedTitle = titleDraft.trim(); + if (!trimmedTitle) { + setRenameError("Thread title is required."); + return; + } + + if (trimmedTitle === thread.title) { + setIsEditingTitle(false); + setRenameError(null); + return; + } + + setRenamePending(true); + setRenameError(null); + try { + await onRename(trimmedTitle); + setIsEditingTitle(false); + } catch (error) { + setRenameError(error instanceof Error ? error.message : String(error)); + } finally { + setRenamePending(false); + } + }; return (
-
+
Active Thread
{isReplayRequired && ( @@ -74,9 +132,89 @@ export const ChatThreadHeader: FunctionComponent = ({ )}
-

- {thread?.title || "No Thread Selected"} -

+ {isEditingTitle && thread ? ( +
+ +
+ { + setTitleDraft(event.currentTarget.value); + if (renameError) { + setRenameError(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + void saveRename(); + } + }} + className="min-h-11 min-w-0 flex-1 rounded-2xl border border-black/[0.08] bg-white/75 px-3 py-2 font-display text-xl font-semibold tracking-tight text-slate-900 outline-none transition focus:border-signal-500 focus:ring-2 focus:ring-signal-500/25 disabled:cursor-wait disabled:opacity-70 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white" + /> +
+ + +
+
+ {renameError && titleErrorId && ( + + )} +
+ ) : ( +
+

+ {thread?.title || "No Thread Selected"} +

+ {thread && ( + + )} +
+ )}
diff --git a/dashboard/src/v2/components/chat/ThreadListCard.tsx b/dashboard/src/v2/components/chat/ThreadListCard.tsx index 5d8179ab2f..9ace717c08 100644 --- a/dashboard/src/v2/components/chat/ThreadListCard.tsx +++ b/dashboard/src/v2/components/chat/ThreadListCard.tsx @@ -103,7 +103,7 @@ export const ThreadListCard: FunctionComponent<{
{/* Title */} -

+

{thread.title}

diff --git a/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx b/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx index d4f23d1046..b09c1ec2b1 100644 --- a/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx +++ b/dashboard/src/v2/components/chat/__tests__/ChatPage.accessibility.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/preact'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/preact'; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import * as matchers from '@testing-library/jest-dom/matchers'; expect.extend(matchers); @@ -59,6 +60,7 @@ const mocks = vi.hoisted(() => { handleSend: vi.fn(), navigateHistory: vi.fn(() => false), handleDeleteThread: vi.fn(), + handleRenameThread: vi.fn(() => Promise.resolve()), createThreadForCompose: vi.fn(), threadIndex: new Map(), invocationIndex: new Map(), @@ -109,6 +111,10 @@ vi.mock('../../../hooks/use-project-effective-settings.js', () => ({ })); describe('ChatPage Accessibility', () => { + afterEach(() => { + cleanup(); + }); + beforeEach(() => { vi.clearAllMocks(); mocks.reducedMotion.value = false; @@ -125,6 +131,7 @@ describe('ChatPage Accessibility', () => { input: "Ship it", error: "Test error", feedback: { status: "idle", message: null }, + handleRenameThread: vi.fn(() => Promise.resolve()), }; }); @@ -220,9 +227,9 @@ describe('ChatPage Accessibility', () => { const detailPanel = screen.getByText('Transcript').closest('section'); const splitPane = detailPanel?.parentElement; - expect(rail).toHaveClass('h-full', 'overflow-hidden', 'lg:max-h-full'); + expect(rail).toHaveClass('overflow-hidden', 'md:h-full', 'md:max-h-none'); expect(detailPanel).toHaveClass('min-h-0', 'overflow-hidden'); - expect(splitPane).toHaveClass('min-h-0', 'overflow-hidden', 'lg:grid-rows-[minmax(0,1fr)]'); + expect(splitPane).toHaveClass('min-h-0', 'overflow-hidden', 'md:grid-rows-[minmax(0,1fr)]'); }); it('has accessible message composer and regions', () => { @@ -255,6 +262,49 @@ describe('ChatPage Accessibility', () => { expect(liveError).toHaveAttribute('aria-live', 'polite'); }); + it('keeps the active header and thread rail synchronized after rename', async () => { + const user = userEvent.setup(); + const initialThread = { ...mocks.data.threads[0], title: "Thread 1" }; + mocks.data = { + ...mocks.data, + threads: [initialThread], + selectedThread: initialThread, + selectedThreadId: initialThread.id, + threadIndex: new Map([[initialThread.id, initialThread]]), + sending: false, + input: "", + error: null, + }; + mocks.data.handleRenameThread = vi.fn(async (title: string) => { + const updatedThread = { ...initialThread, title, updatedAt: "2026-03-10T12:01:00.000Z" }; + mocks.data.threads = [updatedThread]; + mocks.data.selectedThread = updatedThread; + mocks.data.threadIndex = new Map([[updatedThread.id, updatedThread]]); + }); + + const renderPage = () => ( + + + + ); + const { rerender } = render(renderPage()); + + await user.click(screen.getByRole("button", { name: "Rename Thread 1" })); + const titleInput = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(titleInput); + await user.type(titleInput, "Renamed Session"); + await user.click(screen.getByRole("button", { name: "Save thread title" })); + + await waitFor(() => { + expect(mocks.data.handleRenameThread).toHaveBeenCalledWith("Renamed Session"); + }); + + rerender(renderPage()); + + expect(screen.getAllByRole("heading", { name: "Renamed Session" })).toHaveLength(2); + expect(screen.getAllByText("Renamed Session")).toHaveLength(2); + }); + it('suppresses duplicate invocation restarts and shows retry feedback', async () => { mocks.data = { ...mocks.data, diff --git a/dashboard/src/v2/hooks/use-chat-page-data.ts b/dashboard/src/v2/hooks/use-chat-page-data.ts index 1c5f8ee315..b1d0dfe16a 100644 --- a/dashboard/src/v2/hooks/use-chat-page-data.ts +++ b/dashboard/src/v2/hooks/use-chat-page-data.ts @@ -230,6 +230,7 @@ export const useChatPageData = (options?: { composerRef?: RefObject => { + if (!selectedThread || !selectedProject) { + throw new Error("Select a thread before renaming it."); + } + + const updated = await updateConversationThread(selectedThread.id, { title }); + const nextThreads = (cache.getThreads(selectedProject.id) || threadsRef.current).map((thread) => ( + thread.id === updated.id ? updated : thread + )); + cache.setThreads(selectedProject.id, nextThreads); + setThreadsSnapshot(nextThreads); + setError(null); + setSuccess("Thread renamed."); + return updated; + }, [cache, selectedProject, selectedThread, setSuccess, setThreadsSnapshot]); + const recordSentMessage = useCallback((message: string): void => { sentHistoryRef.current = [...sentHistoryRef.current.filter((entry) => entry !== message), message].slice(-50); historyIndexRef.current = -1; @@ -509,6 +526,7 @@ export const useChatThreadData = (options: { handleSend, navigateHistory, handleDeleteThread, + handleRenameThread, feedback, clearFeedback, isConfirmOpen, diff --git a/dashboard/src/v2/lib/__tests__/connection-api.test.ts b/dashboard/src/v2/lib/__tests__/connection-api.test.ts new file mode 100644 index 0000000000..f6f34b8b93 --- /dev/null +++ b/dashboard/src/v2/lib/__tests__/connection-api.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchJson } from "../../../lib/api/fetch-json.js"; +import { updateConversationThread } from "../connection-api.js"; + +vi.mock("../../../lib/api/fetch-json.js", () => ({ + fetchJson: vi.fn(), +})); + +describe("connection-api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("patches a conversation thread title without route fields", async () => { + const updatedThread = { + id: "thread-1", + projectId: "project-1", + connectionId: null, + scope: "project", + title: "Renamed Session", + status: "open", + createdAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:01:00.000Z", + messageCount: 1, + pendingMessageCount: 0, + lastMessageAt: null, + lastMessagePreview: null, + }; + vi.mocked(fetchJson).mockResolvedValueOnce(updatedThread); + + const result = await updateConversationThread("thread-1", { title: "Renamed Session" }); + + expect(result).toBe(updatedThread); + expect(fetchJson).toHaveBeenCalledWith( + "/api/conversations/threads/thread-1", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Renamed Session" }), + }, + ); + }); +}); diff --git a/dashboard/src/verify-chat-header.tsx b/dashboard/src/verify-chat-header.tsx index 92cbd0b15e..d5d1e5df81 100644 --- a/dashboard/src/verify-chat-header.tsx +++ b/dashboard/src/verify-chat-header.tsx @@ -36,6 +36,7 @@ const App = () => ( thread={mockThreadActive} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> @@ -45,6 +46,7 @@ const App = () => ( thread={mockThreadReplay} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> @@ -54,6 +56,7 @@ const App = () => ( thread={mockThreadNew} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={() => Promise.resolve()} isCompacting={false} isCancelling={false} /> diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index f0455cee26..55cff1399f 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -19,6 +19,8 @@ A *thread* is a persistent conversation with an agent. Each thread has: New dashboard chat threads derive an 8-word-or-less title from the first visible user message. Code UX stores the title in sqlite and mirrors it to `.code-ux/conversations//session-title.md` inside the project checkout; manual title edits update both places. +To rename a thread, use the edit control beside the active thread title. The inline editor supports pointer and keyboard workflows: Enter saves, Escape cancels, explicit save/cancel buttons are available, and empty titles are rejected before the request is sent. Successful renames update the active header and left rail from the returned backend thread record without reloading the transcript. + To start a new thread, click **+ New thread**. To change the responding agent, open the thread header dropdown and pick from the list of agent presets defined for this project. Each post triggers a routed invocation: the dashboard records the request, dispatches it to the chosen provider via the worker assignment service (routed through the `dashboard_reply` invocation type), and streams the reply back into the thread. diff --git a/docs/architecture/chat-thread-runtime.md b/docs/architecture/chat-thread-runtime.md index bd90a43844..9186e7f9d7 100644 --- a/docs/architecture/chat-thread-runtime.md +++ b/docs/architecture/chat-thread-runtime.md @@ -32,6 +32,8 @@ Automatic worker pickup occurs seamlessly. If a project has an inherited worker UI interactions like invocation stats visibility (`code-ux:invocation-stats-visible`) and optimistic feedback states (e.g., `ActionFeedbackRegion` for refresh/errors and `role='status'` for working bubbles) are explicitly managed and durable across navigation to preserve a responsive, readable chat experience. The Threads/Invocations mode switch is a keyboard-accessible tablist with arrow/Home/End navigation, visible selected-state cues, count/status copy, and static reduced-motion indicators so selection does not depend on animated movement. +Thread title edits use the same durable thread record contract as routing updates. The dashboard sends `PATCH /api/conversations/threads/:threadId` with `{ title }`, blocks empty titles before dispatch, and replaces the returned `ChatThread` in the active thread snapshot and cached rail list so title-only changes are reflected without forcing a transcript reload. + Route resolution now follows this precedence on each posted message: - honor an explicit thread-level worker route when the targeted worker endpoint is still live - otherwise honor an explicit thread-level virtual provider route using the stored provider plus current `dashboard_reply` provider settings for model, API key, and thinking mode diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 75e6cfe629..bfe2df4a95 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -481,6 +481,7 @@ Legacy runtime: - `Settings > Sprint & Git` now includes the QA controls immediately below `Merge Gates & Autofix`, with per-trigger multi-select agent assignment across all project agents, QA-labeled presets floated to the top, the same project-scope behavior preserved for local QA edits, and the persisted settings path still anchored at `agents.qualityAssurance`. Leaving a trigger with no custom agents selected clearly uses the built-in QA fallback without saving placeholder preset ids. - Chat page is DB-backed and stores project conversation threads/messages in sqlite - New dashboard chat threads derive an 8-word-or-less title from the first visible user message and mirror that title to `.code-ux/conversations//session-title.md`; hidden/internal messages do not drive user-facing titles. +- Chat thread titles can be renamed inline from the active thread header. The header editor supports pointer and keyboard use, rejects empty titles before sending, uses `PATCH /api/conversations/threads/:threadId` with `{ title }`, and updates both the active header and thread rail from the returned thread record without replacing the visible transcript. - Chat page now provides a `Threads / Invocations` toggle to switch between human conversation threads and read-only execution invocations. - Chat page UI is redesigned with animated identities, structured widgets for rich messages, and automatic worker pickup derived from active project routing. - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 75fb053c50..aa660dc737 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -28,6 +28,8 @@ The chat and invocation design system for the Code UX dashboard defines the layo - Seamless mode switching between standard "Threads" (user-facing chat) and "Invocations" (runtime debugging transcript). - Consistent padding and gap spacing to prevent layout jitter during these transitions. - The invocation rail renders the first 40 newest invocations by default, then lazy-loads additional pages as the user scrolls near the bottom of the rail. The rail header and mode tab use the backend `totalCount`, not the number of loaded rows, so long-running projects show the real invocation total while keeping initial load lightweight. +- The active thread header includes an inline title editor with explicit save and cancel controls. Enter saves, Escape cancels, empty titles are rejected locally, and pending/error states stay inside the header so the conversation transcript remains stable while the backend returns the updated thread record. +- Thread rail title rows clamp to two stable lines and wrap long words so renamed threads stay readable without resizing the rail unpredictably. ## Accessibility - **Tab Navigation**: The mode switcher is a `role="tablist"` with unique `id`s for `role="tab"` elements, matching `aria-controls` to the underlying `role="tabpanel"` and `aria-labelledby` back to the tab. Roving `tabIndex` and arrow-key navigation are supported. diff --git a/tests/dashboard/v2/chat-thread-header.test.tsx b/tests/dashboard/v2/chat-thread-header.test.tsx index 761715092b..a8b1a9c702 100644 --- a/tests/dashboard/v2/chat-thread-header.test.tsx +++ b/tests/dashboard/v2/chat-thread-header.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment happy-dom /** @jsx h */ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { h } from "preact"; -import { render, screen, fireEvent } from "@testing-library/preact"; +import { cleanup, render, screen, fireEvent } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from '@testing-library/jest-dom/matchers'; import { ChatThreadHeader } from "../../../dashboard/src/v2/components/chat/ChatThreadHeader.js"; @@ -11,6 +11,11 @@ import { buildMockChatThread } from "../factories/chat-fixture-factory.js"; expect.extend(matchers); describe("ChatThreadHeader", () => { + afterEach(() => { + cleanup(); + }); + + const noopRename = vi.fn(() => Promise.resolve()); const baseThread = buildMockChatThread({ id: "t1", projectId: "p1", @@ -32,6 +37,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -47,6 +53,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -61,6 +68,7 @@ describe("ChatThreadHeader", () => { thread={threadActive} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -73,6 +81,7 @@ describe("ChatThreadHeader", () => { thread={threadReplay} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -87,6 +96,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={true} isCancelling={false} /> @@ -104,6 +114,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={onCompact} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -129,6 +140,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -144,6 +156,7 @@ describe("ChatThreadHeader", () => { thread={thread} onCompact={() => {}} onCancelActiveTurn={() => {}} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -160,6 +173,7 @@ describe("ChatThreadHeader", () => { thread={baseThread} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -172,6 +186,7 @@ describe("ChatThreadHeader", () => { thread={{ ...baseThread, pendingMessageCount: 1 }} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={false} /> @@ -187,6 +202,7 @@ describe("ChatThreadHeader", () => { thread={{ ...baseThread, pendingMessageCount: 1 }} onCompact={() => {}} onCancelActiveTurn={onCancelActiveTurn} + onRename={noopRename} isCompacting={false} isCancelling={true} /> @@ -196,4 +212,74 @@ describe("ChatThreadHeader", () => { expect(cancellingButton).toBeDisabled(); expect(cancellingButton).toHaveAttribute("aria-busy", "true"); }); + + it("saves a renamed title from the keyboard", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + await user.type(input, "Renamed Thread{Enter}"); + + expect(onRename).toHaveBeenCalledWith("Renamed Thread"); + }); + + it("rejects empty renamed titles before calling the API", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent("Thread title is required."); + }); + + it("cancels rename edits with Escape", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(() => Promise.resolve()); + render( + {}} + onCancelActiveTurn={() => {}} + onRename={onRename} + isCompacting={false} + isCancelling={false} + /> + ); + + await user.click(screen.getByRole("button", { name: "Rename Test Thread" })); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await user.clear(input); + await user.type(input, "Draft"); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox", { name: "Thread title" })).not.toBeInTheDocument(); + expect(screen.getByText("Test Thread")).toBeInTheDocument(); + }); });