diff --git a/packages/views/editor/title-editor.test.tsx b/packages/views/editor/title-editor.test.tsx
new file mode 100644
index 000000000..ce3232ba3
--- /dev/null
+++ b/packages/views/editor/title-editor.test.tsx
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render } from "@testing-library/react";
+
+const mockFocus = vi.hoisted(() => vi.fn());
+const mockSetContent = vi.hoisted(() => vi.fn());
+const mockBlur = vi.hoisted(() => vi.fn());
+const editorState = vi.hoisted(() => ({
+ isFocused: false,
+ isDestroyed: false,
+ text: "",
+}));
+
+vi.mock("../i18n", () => ({
+ useT: () => ({ t: (fn: unknown) => (typeof fn === "function" ? "" : "") }),
+}));
+
+const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
+
+vi.mock("@tiptap/react", () => ({
+ useEditor: () => {
+ if (!editorRef.current) {
+ editorRef.current = {
+ get isFocused() {
+ return editorState.isFocused;
+ },
+ get isDestroyed() {
+ return editorState.isDestroyed;
+ },
+ commands: {
+ focus: mockFocus,
+ blur: mockBlur,
+ setContent: mockSetContent,
+ },
+ getText: () => editorState.text,
+ };
+ }
+ return editorRef.current;
+ },
+ EditorContent: () =>
,
+}));
+
+import { TitleEditor } from "./title-editor";
+
+describe("TitleEditor", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ editorState.isFocused = false;
+ editorState.isDestroyed = false;
+ editorState.text = "";
+ editorRef.current = null;
+ });
+
+ it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
+ editorState.text = "old title";
+ const { rerender } = render();
+
+ expect(mockSetContent).not.toHaveBeenCalled();
+
+ rerender();
+
+ expect(mockSetContent).toHaveBeenCalledTimes(1);
+ expect(mockSetContent).toHaveBeenCalledWith(
+ {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "new title from server" }],
+ },
+ ],
+ },
+ { emitUpdate: false },
+ );
+ });
+
+ it("does not overwrite the user's in-flight edits when the editor is focused and dirty", () => {
+ editorState.text = "old title";
+ const { rerender } = render();
+
+ editorState.isFocused = true;
+ editorState.text = "user typed but not yet blurred";
+
+ rerender();
+
+ expect(mockSetContent).not.toHaveBeenCalled();
+ });
+
+ // Regression: a focused but clean editor (user clicked in but never typed)
+ // must still accept external updates, otherwise the subsequent blur would
+ // compare stale editor text to the new server value and silently roll the
+ // external update back.
+ it("syncs to new defaultValue when editor is focused but clean", () => {
+ editorState.text = "old title";
+ const { rerender } = render();
+
+ // User clicked into the title field but has not typed anything yet:
+ // editor text still equals the previous defaultValue.
+ editorState.isFocused = true;
+ editorState.text = "old title";
+
+ rerender();
+
+ expect(mockSetContent).toHaveBeenCalledTimes(1);
+ expect(mockSetContent).toHaveBeenCalledWith(
+ {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "new title from server" }],
+ },
+ ],
+ },
+ { emitUpdate: false },
+ );
+ });
+
+ it("short-circuits when editor text already equals incoming defaultValue", () => {
+ editorState.text = "same title";
+ const { rerender } = render();
+
+ // Force the effect to re-run by rendering with a different prop, then
+ // back to the same value. Even an identity-equal prop should be skipped.
+ rerender();
+
+ expect(mockSetContent).not.toHaveBeenCalled();
+ });
+
+ it("clears the editor when defaultValue transitions to empty", () => {
+ editorState.text = "old title";
+ const { rerender } = render();
+
+ rerender();
+
+ expect(mockSetContent).toHaveBeenCalledTimes(1);
+ expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false });
+ });
+});
diff --git a/packages/views/editor/title-editor.tsx b/packages/views/editor/title-editor.tsx
index 0dfad35e4..2ba4a7eb0 100644
--- a/packages/views/editor/title-editor.tsx
+++ b/packages/views/editor/title-editor.tsx
@@ -132,6 +132,44 @@ const TitleEditor = forwardRef(
return undefined;
}, [autoFocus, editor]);
+ // Track the last `defaultValue` we've reconciled against, so we can tell
+ // "focused + dirty" (user typed something that diverges from external)
+ // apart from "focused + clean" (user just clicked in without typing).
+ const lastDefaultValueRef = useRef(defaultValue);
+
+ // Sync external `defaultValue` changes into the editor.
+ // Tiptap `useEditor` consumes `content` only at mount, so a WS-driven
+ // title update would otherwise leave the editor showing stale text — and
+ // the next blur would silently roll the external change back via onBlur's
+ // value-vs-issue.title compare.
+ useEffect(() => {
+ if (!editor || editor.isDestroyed) return;
+ const prevDefaultValue = lastDefaultValueRef.current;
+ lastDefaultValueRef.current = defaultValue;
+
+ // Already in sync — nothing to do.
+ if (editor.getText() === defaultValue) return;
+
+ // Focused + dirty: editor text diverges from the previous external
+ // value, meaning the user has typed in this session. Preserve input.
+ // Focused + clean (text still equals prev defaultValue) falls through
+ // so we accept the new external value instead of letting the next blur
+ // roll it back.
+ if (editor.isFocused && editor.getText() !== prevDefaultValue) return;
+
+ editor.commands.setContent(
+ defaultValue
+ ? {
+ type: "doc",
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: defaultValue }] },
+ ],
+ }
+ : "",
+ { emitUpdate: false },
+ );
+ }, [defaultValue, editor]);
+
useImperativeHandle(ref, () => ({
getText: () => editor?.getText() ?? "",
focus: () => {