Files
kandev/apps/web/components/quick-chat/quick-chat-tab-item.test.tsx
T
João Salavisa 5f4b3ec0c0 feat(web): allow renaming quick-chat tabs locally (#830)
* feat(web): allow renaming quick-chat tabs locally

Double-click a quick-chat tab to rename it. The name is stored in
localStorage and overlays the SSR-derived task title on hydration, so
renames survive reload without touching the backend.

* fix(web): address quick-chat rename review feedback

- Drop unused sessionId prop from QuickChatTabItem
- Enter handler now blurs the input instead of calling commit(),
  preventing a duplicate commit when onBlur fires on unmount
- Add hydrator tests covering the localStorage name overlay

* fix(web): make Escape actually cancel quick-chat rename

Escape was setting isEditing(false), which unmounted the focused input,
causing onBlur (commit) to fire synchronously with the typed draft still
in the closure — committing the rename instead of discarding it.

Cancel now flips a cancelledRef and blurs the input; commit short-circuits
when the ref is set, so onBlur is the single edit-exit path for both
Enter and Escape. Adds a render test covering both flows.

* fix(web): guard quick-chat rename against IME and orphan storage writes

- Skip Enter/Escape handling while IME composition is active so candidate
  confirmation does not commit/cancel the rename
- renameQuickChatSession only writes to localStorage when the session was
  actually found in state, preventing orphan entries for stale session IDs

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 21:47:35 +01:00

85 lines
3.0 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, fireEvent } from "@testing-library/react";
import { QuickChatTabItem } from "./quick-chat-tab-item";
afterEach(cleanup);
function makeProps(overrides: Partial<Parameters<typeof QuickChatTabItem>[0]> = {}) {
return {
name: "Original",
isActive: true,
isRenameable: true,
onActivate: vi.fn(),
onClose: vi.fn(),
onRename: vi.fn(),
...overrides,
};
}
const RENAME_LABEL = "Rename chat";
function startEditing(label: HTMLElement) {
fireEvent.doubleClick(label);
}
describe("QuickChatTabItem rename", () => {
it("commits the rename on Enter, calling onRename exactly once", () => {
const onRename = vi.fn();
const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />);
startEditing(getByText("Original"));
const input = getByLabelText(RENAME_LABEL) as HTMLInputElement;
fireEvent.change(input, { target: { value: "New name" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledTimes(1);
expect(onRename).toHaveBeenCalledWith("New name");
});
it("discards the draft on Escape — onRename is NOT called even after blur fires on unmount", () => {
const onRename = vi.fn();
const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />);
startEditing(getByText("Original"));
const input = getByLabelText(RENAME_LABEL) as HTMLInputElement;
fireEvent.change(input, { target: { value: "Should be discarded" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
});
it("does not call onRename when the trimmed draft equals the original name", () => {
const onRename = vi.fn();
const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />);
startEditing(getByText("Original"));
const input = getByLabelText(RENAME_LABEL) as HTMLInputElement;
fireEvent.change(input, { target: { value: " Original " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).not.toHaveBeenCalled();
});
it("ignores Enter while IME composition is active", () => {
const onRename = vi.fn();
const { getByText, getByLabelText } = render(<QuickChatTabItem {...makeProps({ onRename })} />);
startEditing(getByText("Original"));
const input = getByLabelText(RENAME_LABEL) as HTMLInputElement;
fireEvent.change(input, { target: { value: "Composing candidate" } });
// Enter pressed while IME is composing — should be a no-op (IME confirms candidate).
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(onRename).not.toHaveBeenCalled();
});
it("does not enter edit mode when isRenameable is false", () => {
const { getByText, queryByLabelText } = render(
<QuickChatTabItem {...makeProps({ isRenameable: false })} />,
);
startEditing(getByText("Original"));
expect(queryByLabelText(RENAME_LABEL)).toBeNull();
});
});