Skip to content
Open
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
93 changes: 90 additions & 3 deletions web/src/components/Tasks/TaskDetailBody.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';

import type { Task } from '../../api/client';
import { api, type Task } from '../../api/client';
import { TaskDetailBody } from './TaskDetailBody';

vi.mock('../../api/client', () => ({
Expand All @@ -27,6 +28,7 @@ function task(content: string): Task & { content: string } {
}

const editor = () => screen.getByPlaceholderText('Task content...');
const saveButton = () => screen.queryByRole('button', { name: /save/i });

async function startEditing() {
await userEvent.click(screen.getByTitle('Edit'));
Expand Down Expand Up @@ -66,3 +68,88 @@ describe('TaskDetailBody content sync', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
});

/**
* A save that fails has to look different from a save that worked. The old
* code cleared `dirty` either way, which hid the Save button and left the
* editor looking settled while the only copy of the text was still in the
* browser — the user found out on the next reload.
*/
describe('TaskDetailBody failed saves', () => {
const updateTask = vi.mocked(api.updateTask);
let consoleError: MockInstance;

const savedResponse = (content: string) => ({
task: task(content), task_id: 't1', updated: true,
});

beforeEach(() => {
updateTask.mockReset();
// The store logs the rejection deliberately. Swallow it here so a genuine
// React warning still stands out in the run output.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleError.mockRestore();
});

async function typeAndFailToSave(addition = ' plus my notes') {
render(<TaskDetailBody task={task('# original')} />);
await startEditing();
await userEvent.type(editor(), addition);
await userEvent.click(screen.getByRole('button', { name: /save/i }));
return screen.findByRole('alert');
}

it('says the save failed and keeps offering the retry', async () => {
updateTask.mockRejectedValue(new Error('network down'));

expect(await typeAndFailToSave()).toHaveTextContent(/save failed/i);

// The edit is still in the box, and still offered for saving. Before the
// fix the button vanished, which read as "saved".
expect(editor()).toHaveValue('# original plus my notes');
expect(saveButton()).toBeInTheDocument();
});

it('keeps the failed edit safe from an update arriving underneath', async () => {
updateTask.mockRejectedValue(new Error('network down'));
const { rerender } = render(<TaskDetailBody task={task('# original')} />);
await startEditing();
await userEvent.type(editor(), ' plus my notes');
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await screen.findByRole('alert');

rerender(<TaskDetailBody task={task('# rewritten elsewhere')} />);

// `dirty` staying set is what keeps the content-sync guard armed. Clearing
// it on a failure would hand the only copy of the text to the next
// re-render, which is how the edit actually got lost.
expect(editor()).toHaveValue('# original plus my notes');
});

it('clears the failure once a retry succeeds', async () => {
updateTask
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(savedResponse('# original!'));

await typeAndFailToSave('!');
await userEvent.click(screen.getByRole('button', { name: /save/i }));

await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(saveButton()).not.toBeInTheDocument();
});

it('clears the failure as soon as the user edits again', async () => {
updateTask.mockRejectedValue(new Error('network down'));

await typeAndFailToSave('!');
await userEvent.type(editor(), '?');

// Stale complaint about text the user has already moved past, but the
// edit is still unsaved so Save has to stay.
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(saveButton()).toBeInTheDocument();
});
});
32 changes: 22 additions & 10 deletions web/src/components/Tasks/TaskDetailBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function TaskDetailBody({ task }: { task: Task }) {
const [showHistory, setShowHistory] = useState(false);
const [localContent, setLocalContent] = useState('');
const [dirty, setDirty] = useState(false);
const [saveError, setSaveError] = useState(false);

// Adopt the loaded markdown, but never clobber unsaved edits.
//
Expand All @@ -40,8 +41,12 @@ export function TaskDetailBody({ task }: { task: Task }) {

const handleSave = useCallback(async () => {
if (!dirty) return;
await saveTaskContent(task.id, localContent);
setDirty(false);
// Keep `dirty` set when the save fails. It is what keeps the Save button
// on screen, so the retry stays available and the editor cannot look
// settled while the content is still only in the browser.
const saved = await saveTaskContent(task.id, localContent);
setSaveError(!saved);
if (saved) setDirty(false);
}, [task.id, dirty, localContent, saveTaskContent]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
Expand Down Expand Up @@ -89,13 +94,20 @@ export function TaskDetailBody({ task }: { task: Task }) {
<History size={13} />
</button>
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] bg-accent hover:bg-accent-hover text-white rounded-md cursor-pointer disabled:opacity-50"
>
<Save size={12} /> {saving ? 'Saving...' : 'Save'}
</button>
<>
{saveError && (
<span role="alert" className="text-[12px] text-hue-red">
Save failed — not saved yet.
</span>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] bg-accent hover:bg-accent-hover text-white rounded-md cursor-pointer disabled:opacity-50"
>
<Save size={12} /> {saving ? 'Saving...' : 'Save'}
</button>
</>
)}
<div className="flex bg-surface-raised rounded-md border border-border">
<button
Expand Down Expand Up @@ -129,7 +141,7 @@ export function TaskDetailBody({ task }: { task: Task }) {
{mode === 'edit' ? (
<textarea
value={localContent}
onChange={(e) => { setLocalContent(e.target.value); setDirty(true); }}
onChange={(e) => { setLocalContent(e.target.value); setDirty(true); setSaveError(false); }}
onKeyDown={handleKeyDown}
className="flex-1 min-h-0 p-5 bg-bg-sunken text-[13px] text-text font-mono leading-relaxed outline-none resize-none"
spellCheck={false}
Expand Down
7 changes: 6 additions & 1 deletion web/src/stores/taskStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ interface TaskState {
setShowCreateDialog: (show: boolean, status?: string | null) => void;

loadTask: (id: string) => Promise<void>;
saveTaskContent: (id: string, content: string) => Promise<void>;
/** Resolves `false` when the write failed, so the caller can keep the edit. */
saveTaskContent: (id: string, content: string) => Promise<boolean>;
clearSelectedTask: () => void;
}

Expand Down Expand Up @@ -433,8 +434,12 @@ export const useTaskStore = create<TaskState>((set, get) => ({
if (sel && sel.id === id) {
set({ selectedTask: { ...sel, content } });
}
return true;
} catch (e) {
// Reported rather than rethrown: the editor needs to know the write
// failed so it can hold on to the text, not to handle the error itself.
console.error('Failed to save task:', e);
return false;
} finally {
set({ saving: false });
}
Expand Down
Loading