Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 16 additions & 21 deletions client/src/components/writers-room/WorkEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -734,31 +734,24 @@ export default function WorkEditor({ work, onChange, onToggleExercise, exerciseO
const runObjects = useCallback(() => runAnalysis(ANALYSIS_KIND.OBJECTS), [runAnalysis]);

return (
<div className="flex flex-col h-full">
{/*
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.
*/}
<div className="flex flex-col flex-1 min-h-0">
{/* Keep the editor mounted when collapsing controls so unsaved prose,
selection and active view survive focused writing. */}
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-port-border bg-port-card">
{headerCollapsed && <span className="flex-1 min-w-0 truncate text-sm font-semibold text-white">{title}</span>}
<input
hidden={headerCollapsed}
value={title}
onChange={(e) => 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. */}
<div className="w-full order-last flex items-center gap-2 sm:contents" data-testid="work-header-secondary">
<div className={headerCollapsed ? 'hidden' : 'w-full order-last flex items-center gap-2 sm:contents'} data-testid="work-header-secondary">
<select
value={status}
onChange={(e) => commitStatus(e.target.value)}
Expand Down Expand Up @@ -793,23 +786,25 @@ export default function WorkEditor({ work, onChange, onToggleExercise, exerciseO
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`flex items-center gap-1 px-3 py-1 min-h-[44px] sm:min-h-0 text-xs rounded ${
dirty && !saving ? 'bg-port-accent text-white hover:bg-port-accent/80' : 'bg-port-bg text-gray-500'
className={`flex shrink-0 items-center justify-center gap-1 w-24 px-3 py-1 min-h-[44px] sm:min-h-0 text-xs rounded ${
dirty ? 'bg-port-accent text-white hover:bg-port-accent/80' : 'bg-port-bg text-gray-500'
}`}
title={dirty ? `Save (${modKey}+S)` : 'Up to date'}
aria-busy={saving}
title={saving ? 'Saving draft…' : dirty ? `Save (${modKey}+S)` : 'Up to date'}
>
<Save size={12} /> {saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
<Save size={12} className="shrink-0" />
<span key={saving ? 'saving' : dirty ? 'dirty' : 'saved'}>{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}</span>
</button>
<button
onClick={handleSnapshot}
disabled={dirty}
aria-label="Snapshot"
className="flex items-center gap-1 px-3 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 disabled:text-gray-600 disabled:cursor-not-allowed"
className={`${headerCollapsed ? 'hidden' : 'flex'} items-center gap-1 px-3 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 disabled:text-gray-600 disabled:cursor-not-allowed`}
title="Snapshot the active draft as a new version"
>
<GitCommit size={12} /> <span className="hidden sm:inline">Snapshot</span>
</button>
<div className="relative" ref={overflowRef}>
<div className={headerCollapsed ? 'hidden' : 'relative'} ref={overflowRef}>
<button
onClick={() => 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"
Expand Down
57 changes: 57 additions & 0 deletions client/src/components/writers-room/WorkEditor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div>Catalog cast controls</div> }));
vi.mock('./LibraryPane', () => ({ default: () => <div>Library controls</div> }));

import WorkEditor from './WorkEditor';
import WritersRoom from '../../pages/WritersRoom';
import { getWritersRoomWork, saveWritersRoomDraft } from '../../services/apiWritersRoom';

const work = {
id: 'wr-work-1',
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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: <WritersRoom /> },
], { initialEntries: ['/writers-room/works/wr-work-1'] });
const { container } = render(<RouterProvider router={router} />);
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.');
});
});
73 changes: 35 additions & 38 deletions client/src/pages/WritersRoom.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -127,11 +125,8 @@ export default function WritersRoom() {

return (
<div className="flex flex-col h-full">
{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.
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-port-border bg-port-card">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-port-border bg-port-card shrink-0">
{libraryCollapsed && (
<button
onClick={toggleLibrary}
className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-400 hover:text-white transition-colors"
Expand All @@ -140,33 +135,34 @@ export default function WritersRoom() {
>
<PanelLeftOpen size={16} />
</button>
<NotebookPen className="w-4 h-4 text-port-accent" />
<span className="text-sm font-semibold text-white">Writers Room</span>
<Link
to="/writers-room/guide"
className="ml-auto flex items-center gap-1 text-xs text-gray-400 hover:text-port-accent transition-colors"
title="Writing guide: length targets & craft rules"
aria-label="Writing guide"
>
<BookOpen size={14} />
<span className="hidden sm:inline">Guide</span>
</Link>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-3 border-b border-port-border bg-port-card">
<NotebookPen className="w-5 h-5 text-port-accent" />
<h1 className="text-xl font-bold text-white">Writers Room</h1>
<span className="text-xs text-gray-500 hidden lg:inline">Folders, works, drafts, storyboard, and write-for-10 sprints</span>
<Link
to="/writers-room/guide"
className="ml-auto flex items-center gap-1 text-xs text-gray-400 hover:text-port-accent transition-colors"
title="Writing guide: length targets & craft rules"
)}
<NotebookPen className="w-4 h-4 shrink-0 text-port-accent" />
<h1 className="text-sm font-semibold text-white">Writers Room</h1>
<Link
to="/writers-room/guide"
className="ml-auto min-h-[44px] min-w-[44px] flex items-center justify-center gap-1 text-xs text-gray-400 hover:text-port-accent transition-colors"
title="Writing guide: length targets & craft rules"
aria-label="Writing guide"
>
<BookOpen size={14} />
<span className="hidden sm:inline">Guide</span>
</Link>
{activeWork && (
<button
type="button"
onClick={() => {
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"
>
<BookOpen size={15} />
<span>Guide</span>
</Link>
</div>
)}
{headerCollapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
</button>
)}
</div>

<div
className="flex-1 flex flex-col md:grid min-h-0 transition-[grid-template-columns] duration-200"
Expand Down Expand Up @@ -200,7 +196,7 @@ export default function WritersRoom() {
)}
{!loadingWork && activeWork && (
<>
<div className="px-3 pt-3">
<div className={headerCollapsed ? 'hidden' : 'px-3 pt-3'}>
<CatalogCastPanel
refKind="work"
refId={activeWork.id}
Expand All @@ -209,6 +205,7 @@ export default function WritersRoom() {
</div>
<WorkEditor
work={activeWork}
headerCollapsed={headerCollapsed}
onChange={handleWorkChange}
onToggleExercise={() => setShowExercise((s) => !s)}
exerciseOpen={showExercise}
Expand Down