diff --git a/client/src/components/writers-room/WorkEditor.jsx b/client/src/components/writers-room/WorkEditor.jsx
index 3dd6fec67e..cbbba907b5 100644
--- a/client/src/components/writers-room/WorkEditor.jsx
+++ b/client/src/components/writers-room/WorkEditor.jsx
@@ -103,7 +103,7 @@ function readSidebarTab() {
return STORYBOARD_TAB_VALUES.includes(stored) ? stored : STORYBOARD_TAB.BOARDS;
}
-export default function WorkEditor({ work, onChange, onToggleExercise, exerciseOpen, onDirtyChange }) {
+export default function WorkEditor({ work, onChange, onToggleExercise, exerciseOpen, onDirtyChange, headerCollapsed = false }) {
const navigate = useNavigate();
const [body, setBody] = useState(work.activeDraftBody || '');
const [title, setTitle] = useState(work.title);
@@ -734,31 +734,24 @@ export default function WorkEditor({ work, onChange, onToggleExercise, exerciseO
const runObjects = useCallback(() => runAnalysis(ANALYSIS_KIND.OBJECTS), [runAnalysis]);
return (
-
- {/*
- Header — one wrapping row on desktop, two compact rows on phones (#3568).
- Under `sm` the status select and view-mode toggle move to a full-width
- sub-bar of their own (`order-last`) so the first row is just title, Save,
- Snapshot and the Work menu; at `sm+` the sub-bar is `display: contents`,
- generating no box, so its children are direct flex items of the header
- row again. DOM order is therefore the desktop order — no `order` classes
- on the controls themselves — which keeps desktop tab order matching what
- is on screen. Every control stays visible at every width; nothing was
- pushed into the overflow menu to buy the space.
- */}
+
+ {/* Keep the editor mounted when collapsing controls so unsaved prose,
+ selection and active view survive focused writing. */}
+ {headerCollapsed &&
{title} }
setTitle(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
- className="bg-transparent text-base font-semibold text-white border-none focus:outline-none focus:bg-port-bg/50 px-1 rounded flex-1 min-w-[140px] sm:min-w-[180px] min-h-[44px] sm:min-h-0"
+ className="bg-transparent text-base font-semibold text-white border-none focus:outline-none focus:bg-port-bg/50 px-1 rounded flex-1 min-w-0 w-0 sm:w-auto sm:min-w-[180px] min-h-[44px] sm:min-h-0"
aria-label="Work title"
/>
{/* Secondary controls. `w-full order-last` gives them their own compact
row below the primary actions under `sm`; `sm:contents` dissolves the
wrapper so they sit inline — in this DOM order — on desktop. */}
-
+
commitStatus(e.target.value)}
@@ -793,23 +786,25 @@ export default function WorkEditor({ work, onChange, onToggleExercise, exerciseO
- {saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
+
+ {saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
Snapshot
-
+
setOverflowOpen((v) => !v)}
className="flex items-center justify-center px-3 sm:px-2 py-1 min-h-[44px] sm:min-h-0 text-xs rounded bg-port-bg border border-port-border text-gray-300 hover:text-white"
diff --git a/client/src/components/writers-room/WorkEditor.test.jsx b/client/src/components/writers-room/WorkEditor.test.jsx
index 38596eed9c..9766c0de9e 100644
--- a/client/src/components/writers-room/WorkEditor.test.jsx
+++ b/client/src/components/writers-room/WorkEditor.test.jsx
@@ -27,12 +27,21 @@ vi.mock('../../services/socket', () => ({
vi.mock('../../services/apiWritersRoom', async (importOriginal) => ({
...(await importOriginal()),
+ listWritersRoomFolders: vi.fn(async () => []),
+ listWritersRoomWorks: vi.fn(async () => []),
+ getWritersRoomWork: vi.fn(),
+ saveWritersRoomDraft: vi.fn(),
listWritersRoomCharacters: vi.fn(async () => []),
listWritersRoomPlaces: vi.fn(async () => []),
listWritersRoomObjects: vi.fn(async () => []),
}));
+vi.mock('../CatalogCastPanel', () => ({ default: () => Catalog cast controls
}));
+vi.mock('./LibraryPane', () => ({ default: () => Library controls
}));
+
import WorkEditor from './WorkEditor';
+import WritersRoom from '../../pages/WritersRoom';
+import { getWritersRoomWork, saveWritersRoomDraft } from '../../services/apiWritersRoom';
const work = {
id: 'wr-work-1',
@@ -98,6 +107,9 @@ describe('WorkEditor header layout (#3568)', () => {
const secondary = screen.getByTestId('work-header-secondary');
expect([...header.children]).toEqual([title, secondary, save, snapshot, menu]);
+ // Let a long title yield to the fixed-width Save control on narrow phones.
+ expect(title).toHaveClass('min-w-0', 'w-0');
+ expect(save).toHaveClass('w-24', 'shrink-0');
// Only the sub-bar is re-ordered; anything else carrying an `order-*` class
// would either break the row split or desync tab order from the layout.
for (const el of [title, save, snapshot, menu]) {
@@ -270,3 +282,48 @@ describe('WorkEditor server-pushed draft body (#5300)', () => {
expect(container.querySelector('textarea').value).toBe('The hero waits.');
});
});
+
+
+describe('Writers Room focused writing', () => {
+ it('preserves edits through header collapse and shows accurate save states during continued typing and retry', async () => {
+ getWritersRoomWork.mockResolvedValue(work);
+ const router = createMemoryRouter([
+ { path: '/writers-room/works/:workId', element: },
+ ], { initialEntries: ['/writers-room/works/wr-work-1'] });
+ const { container } = render( );
+ await act(async () => {});
+ const area = container.querySelector('textarea');
+ await act(async () => { fireEvent.change(area, { target: { value: 'First edit.' } }); });
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Collapse writing header' })); });
+ expect(screen.getByRole('button', { name: 'Expand writing header' })).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.getByText('Catalog cast controls').parentElement).toHaveClass('hidden');
+ expect(screen.queryByText('Library controls')).not.toBeInTheDocument();
+ expect(screen.getByTestId('work-header-secondary')).toHaveClass('hidden');
+ expect(container.querySelector('textarea')).toBe(area);
+ expect(area.value).toBe('First edit.');
+
+ let finishSave;
+ saveWritersRoomDraft.mockImplementationOnce(() => new Promise((resolve) => { finishSave = resolve; }));
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); });
+ expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-busy', 'true');
+ await act(async () => { fireEvent.change(area, { target: { value: 'Second edit.' } }); });
+ await act(async () => { finishSave({ ...work, activeDraftBody: 'First edit.' }); });
+ expect(area.value).toBe('Second edit.');
+ expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
+
+ saveWritersRoomDraft.mockRejectedValueOnce(new Error('Example failure'));
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); });
+ expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
+ expect(area.value).toBe('Second edit.');
+ saveWritersRoomDraft.mockResolvedValueOnce({ ...work, activeDraftBody: 'Second edit.' });
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); });
+ expect(screen.getByRole('button', { name: 'Saved' })).toBeDisabled();
+ expect(saveWritersRoomDraft).toHaveBeenLastCalledWith(work.id, 'Second edit.', { silent: true });
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Expand writing header' })); });
+ expect(screen.getByText('Catalog cast controls').parentElement).not.toHaveClass('hidden');
+ expect(screen.getByTestId('work-header-secondary')).not.toHaveClass('hidden');
+ expect(container.querySelector('textarea')).toBe(area);
+ expect(area.value).toBe('Second edit.');
+ });
+});
diff --git a/client/src/pages/WritersRoom.jsx b/client/src/pages/WritersRoom.jsx
index ab448b2dce..491ba20117 100644
--- a/client/src/pages/WritersRoom.jsx
+++ b/client/src/pages/WritersRoom.jsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
-import { NotebookPen, PanelLeftOpen, BookOpen } from 'lucide-react';
+import { NotebookPen, PanelLeftOpen, BookOpen, ChevronUp, ChevronDown } from 'lucide-react';
import LibraryPane from '../components/writers-room/LibraryPane';
import WorkEditor from '../components/writers-room/WorkEditor';
import ExercisePanel from '../components/writers-room/ExercisePanel';
@@ -24,11 +24,9 @@ export default function WritersRoom() {
const [loadingWork, setLoadingWork] = useState(false);
const [showExercise, setShowExercise] = useState(false);
const [editorDirty, setEditorDirty] = useState(false);
- // Header + library collapse state. Opening a work auto-collapses both (see
- // selectWork) so the editor gets maximum room — this now applies on mobile
- // too, where the library is an inline block stacked above the editor. The
- // full header shrinks to a slim bar that hosts the "show library" control.
- // Persisted so a manual collapse survives reloads.
+ const [headerCollapsed, , toggleHeader] = useLocalStorageBool('wr.headerCollapsed', false);
+ // Opening a work collapses the library on mobile and desktop. Header
+ // controls have their own persisted disclosure for focused writing.
const [libraryCollapsed, setLibraryCollapsed] = useLocalStorageBool(LIBRARY_COLLAPSED_KEY, false);
const toggleLibrary = useCallback(() => {
setLibraryCollapsed((prev) => !prev);
@@ -127,11 +125,8 @@ export default function WritersRoom() {
return (
- {libraryCollapsed ? (
- // Slim header while editing — frees vertical room and hosts the "show
- // library" control. This replaces the old desktop-only floating expand
- // button so it's reachable on mobile, where there's no side rail.
-
+
+ {libraryCollapsed && (
-
-
Writers Room
-
-
-
Guide
-
-
- ) : (
-
-
-
Writers Room
- Folders, works, drafts, storyboard, and write-for-10 sprints
-
+ Writers Room
+
+
+ Guide
+
+ {activeWork && (
+ {
+ if (!headerCollapsed) setLibraryCollapsed(true);
+ toggleHeader();
+ }}
+ aria-expanded={!headerCollapsed}
+ aria-label={headerCollapsed ? 'Expand writing header' : 'Collapse writing header'}
+ title={headerCollapsed ? 'Expand writing header' : 'Collapse writing header'}
+ className="shrink-0 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-white"
>
-
- Guide
-
-
- )}
+ {headerCollapsed ?
:
}
+
+ )}
+
-
+
setShowExercise((s) => !s)}
exerciseOpen={showExercise}