5f4b3ec0c0
* 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>
85 lines
3.0 KiB
TypeScript
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();
|
|
});
|
|
});
|