diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css
index 62be5ecd5..9c3973930 100644
--- a/src/components/ai-edition/v4/EditorShellV4.module.css
+++ b/src/components/ai-edition/v4/EditorShellV4.module.css
@@ -119,10 +119,25 @@
flex-shrink: 0;
transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
-.ghostBtn:hover {
+.ghostBtn:hover:not(:disabled) {
background: var(--surface-1);
border-color: var(--border);
}
+.ghostBtn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.projectNameInput {
+ height: 30px;
+ min-width: 160px;
+ padding: 0 10px;
+ border-radius: 9px;
+ border: 1px solid var(--accent);
+ background: var(--surface);
+ color: var(--fg);
+ font: 500 13px var(--font-display);
+ outline: none;
+}
.saved {
display: inline-flex;
align-items: center;
diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx
new file mode 100644
index 000000000..0dade6d48
--- /dev/null
+++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx
@@ -0,0 +1,122 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+// ProjectNameField is a private helper inside EditorTopBar, so reach it through
+// the public topbar instead. The translator echoes keys; assertions read better
+// against keys than against prose that drifts with copy edits.
+vi.mock("@/contexts/I18nContext", () => ({
+ useI18n: () => ({ locale: "en", setLocale: () => {} }),
+ useScopedT: () => (key: string) => key,
+}));
+
+vi.mock("@/hooks/useTheme", () => ({
+ useTheme: () => ({ theme: "dark", toggle: () => {} }),
+}));
+
+import { EditorTopBar } from "./EditorTopBar";
+
+const noop = () => {};
+
+function renderTopBar(projectTitle: string | null) {
+ const onRename = vi.fn();
+ render(
+ ,
+ );
+ return { onRename };
+}
+
+describe("ProjectNameField (issue #180)", () => {
+ it("renders the project title on the button", () => {
+ renderTopBar("Demo Project");
+ expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent(
+ "Demo Project",
+ );
+ });
+
+ it("shows a placeholder and is disabled when no project is loaded", () => {
+ renderTopBar(null);
+ const button = screen.getByRole("button", { name: "topbar.renameProject" });
+ expect(button).toBeDisabled();
+ expect(button).toHaveTextContent("topbar.noProject");
+ });
+
+ it("swaps to an input pre-filled with the title on click and selects it", () => {
+ renderTopBar("Demo Project");
+ fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
+ const input = screen.getByRole("textbox") as HTMLInputElement;
+ expect(input.value).toBe("Demo Project");
+ // The text is selected on focus, so a keystroke replaces the whole title.
+ fireEvent.change(input, { target: { value: "Renamed" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(input).not.toBeInTheDocument();
+ });
+
+ it("commits a typed title on Enter via onRename", () => {
+ const { onRename } = renderTopBar("Demo Project");
+ fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "Renamed" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onRename).toHaveBeenCalledWith("Renamed");
+ });
+
+ it("commits on blur when the title was edited", () => {
+ const { onRename } = renderTopBar("Demo Project");
+ fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "Blurred rename" } });
+ fireEvent.blur(input);
+ expect(onRename).toHaveBeenCalledWith("Blurred rename");
+ });
+
+ it("rejects an empty / whitespace-only rename", () => {
+ const { onRename } = renderTopBar("Demo Project");
+ fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: " " } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onRename).not.toHaveBeenCalled();
+ });
+
+ it("cancels on Escape without calling onRename", () => {
+ const { onRename } = renderTopBar("Demo Project");
+ fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
+ const input = screen.getByRole("textbox");
+ fireEvent.change(input, { target: { value: "Half-typed" } });
+ fireEvent.keyDown(input, { key: "Escape" });
+ expect(onRename).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent(
+ "Demo Project",
+ );
+ });
+
+ it("keeps the rename button in the no-drag region (regression for #180)", () => {
+ // The pre-fix button used `style={{ all: "unset" }}` which clobbered the
+ // topbar's `-webkit-app-region: no-drag` rule. In the Electron build the
+ // button then becomes a window-drag handle and the click never fires the
+ // onClick handler. The CSS module class must keep the no-drag property.
+ renderTopBar("Demo Project");
+ const button = screen.getByRole("button", { name: "topbar.renameProject" });
+ // jsdom doesn't honour `-webkit-app-region`, so assert the marker
+ // indirectly via the inline-style rule we removed: the pre-fix button
+ // had `all: unset`; if any element still has it, the regression is back.
+ expect(button.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/);
+ });
+});
diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx
index 75ef8d260..f91a91be6 100644
--- a/src/components/ai-edition/v4/EditorTopBar.tsx
+++ b/src/components/ai-edition/v4/EditorTopBar.tsx
@@ -205,26 +205,25 @@ function ProjectNameField({
const t = useScopedT("editor");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title ?? "");
- const inputRef = useRef(null);
- useEffect(() => {
- if (editing) {
- setDraft(title ?? "");
- inputRef.current?.select();
- }
- }, [editing, title]);
+ const startEditing = () => {
+ setDraft(title ?? "");
+ setEditing(true);
+ };
const commit = () => {
setEditing(false);
const next = draft.trim();
- if (next && next !== title) onRename(next);
+ if (next) onRename(next);
};
if (editing) {
return (
e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -235,39 +234,20 @@ function ProjectNameField({
setEditing(false);
}
}}
- style={{
- height: 30,
- minWidth: 160,
- padding: "0 10px",
- borderRadius: 9,
- border: "1px solid var(--accent)",
- background: "var(--surface)",
- color: "var(--fg)",
- font: "500 13px var(--font-display)",
- outline: "none",
- }}
/>
);
}
return (
-
-
-
+
);
}