Files
kandev/apps/web/lib/state/hydration/hydrator.test.ts
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

74 lines
2.3 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { produce } from "immer";
import type { Draft } from "immer";
import { hydrateUI } from "./hydrator";
import { defaultUIState } from "@/lib/state/slices/ui/ui-slice";
import type { AppState } from "@/lib/state/store";
function makeDraft(): AppState {
// hydrateUI only touches UI-slice fields; an empty object cast satisfies
// the rest without dragging the full AppState shape into this test.
return { ...defaultUIState } as unknown as AppState;
}
describe("hydrateUI — quick chat name overlay", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("overlays a locally-renamed name onto the SSR-provided session name", () => {
window.localStorage.setItem(
"kandev.quickChat.names",
JSON.stringify({ "sess-1": "My custom name" }),
);
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
hydrateUI(draft, {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
},
});
});
expect(result.quickChat.sessions[0].name).toBe("My custom name");
});
it("keeps the SSR-provided name when no local rename exists", () => {
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
hydrateUI(draft, {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
},
});
});
expect(result.quickChat.sessions[0].name).toBe("Agent A - Chat 1");
});
it("only overlays sessions that have a stored rename, leaving siblings untouched", () => {
window.localStorage.setItem(
"kandev.quickChat.names",
JSON.stringify({ "sess-a": "Renamed A" }),
);
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
hydrateUI(draft, {
quickChat: {
isOpen: false,
activeSessionId: null,
sessions: [
{ sessionId: "sess-a", workspaceId: "ws-1", name: "Original A" },
{ sessionId: "sess-b", workspaceId: "ws-1", name: "Original B" },
],
},
});
});
expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]);
});
});