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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ Before marking feature work complete:
- [ ] Local test run passes for impacted packages
- [ ] CI test checks are expected to pass with the changes

### UI Form Behavior Test Rules (REQUIRED FOR FORM CHANGES)

When changing any UI form or input handling logic, apply all of these rules:

1. **MUST** add/extend tests that simulate realistic typing sequences (single char -> multi-char -> edit) for each changed text field
2. **MUST** cover all changed input types (text, textarea, number, select, checkbox/radio where applicable), not just one happy-path field
3. **MUST** include at least one integration-level test that exercises the full user flow (open form -> type -> submit -> assert payload/API call)
4. **MUST** include assertions that no crash/regression occurs during rapid consecutive changes
5. **MUST** avoid reading React synthetic event objects inside deferred/functional state updaters; capture `const value = event.currentTarget.value` before async or queued updates
6. **SHOULD** include edge-case behavior for user edits (clearing fields, trimming, retyping, invalid intermediate numeric input)

---

## CRITICAL: Constitution Validation (NON-NEGOTIABLE)
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ Before marking feature work complete:
- [ ] Local test run passes for impacted packages
- [ ] CI test checks are expected to pass with the changes

### UI Form Behavior Test Rules (REQUIRED FOR FORM CHANGES)

When changing any UI form or input handling logic, apply all of these rules:

1. **MUST** add/extend tests that simulate realistic typing sequences (single char -> multi-char -> edit) for each changed text field
2. **MUST** cover all changed input types (text, textarea, number, select, checkbox/radio where applicable), not just one happy-path field
3. **MUST** include at least one integration-level test that exercises the full user flow (open form -> type -> submit -> assert payload/API call)
4. **MUST** include assertions that no crash/regression occurs during rapid consecutive changes
5. **MUST** avoid reading React synthetic event objects inside deferred/functional state updaters; capture `const value = event.currentTarget.value` before async or queued updates
6. **SHOULD** include edge-case behavior for user edits (clearing fields, trimming, retyping, invalid intermediate numeric input)

---

## CRITICAL: Constitution Validation (NON-NEGOTIABLE)
Expand Down
28 changes: 22 additions & 6 deletions apps/web/src/components/project/TaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function TaskForm({
const [error, setError] = useState<string | null>(null);

const candidateParents = tasks.filter((task) => task.id !== currentTaskId);
const updateField = <K extends keyof TaskFormValues>(field: K, value: TaskFormValues[K]) => {
setValues((current) => ({ ...current, [field]: value }));
};

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -68,7 +71,10 @@ export function TaskForm({
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Title</span>
<Input
value={values.title}
onChange={(event) => setValues((current) => ({ ...current, title: event.currentTarget.value }))}
onChange={(event) => {
const value = event.currentTarget.value;
updateField('title', value);
}}
placeholder="Task title"
disabled={submitting}
/>
Expand All @@ -78,7 +84,10 @@ export function TaskForm({
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Description</span>
<textarea
value={values.description}
onChange={(event) => setValues((current) => ({ ...current, description: event.currentTarget.value }))}
onChange={(event) => {
const value = event.currentTarget.value;
updateField('description', value);
}}
rows={3}
disabled={submitting}
style={{
Expand All @@ -99,8 +108,9 @@ export function TaskForm({
type="number"
value={String(values.priority)}
onChange={(event) => {
const parsed = Number.parseInt(event.currentTarget.value, 10);
setValues((current) => ({ ...current, priority: Number.isNaN(parsed) ? 0 : parsed }));
const rawValue = event.currentTarget.value;
const parsed = Number.parseInt(rawValue, 10);
updateField('priority', Number.isNaN(parsed) ? 0 : parsed);
}}
disabled={submitting}
/>
Expand All @@ -110,7 +120,10 @@ export function TaskForm({
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Parent task</span>
<select
value={values.parentTaskId}
onChange={(event) => setValues((current) => ({ ...current, parentTaskId: event.currentTarget.value }))}
onChange={(event) => {
const value = event.currentTarget.value;
updateField('parentTaskId', value);
}}
disabled={submitting}
style={{
width: '100%',
Expand All @@ -135,7 +148,10 @@ export function TaskForm({
<span style={{ fontSize: '0.875rem', color: 'var(--sam-color-fg-muted)' }}>Agent hint</span>
<Input
value={values.agentProfileHint}
onChange={(event) => setValues((current) => ({ ...current, agentProfileHint: event.currentTarget.value }))}
onChange={(event) => {
const value = event.currentTarget.value;
updateField('agentProfileHint', value);
}}
placeholder="Optional agent profile hint"
disabled={submitting}
/>
Expand Down
90 changes: 90 additions & 0 deletions apps/web/tests/unit/components/project/task-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Task } from '@simple-agent-manager/shared';
import { TaskForm } from '../../../../src/components/project/TaskForm';

const taskA: Task = {
id: 'task-a',
projectId: 'proj-1',
userId: 'user-1',
parentTaskId: null,
workspaceId: null,
title: 'Task A',
description: 'Task A description',
status: 'draft',
priority: 1,
agentProfileHint: null,
blocked: false,
startedAt: null,
completedAt: null,
errorMessage: null,
outputSummary: null,
outputBranch: null,
outputPrUrl: null,
createdAt: '2026-02-18T00:00:00.000Z',
updatedAt: '2026-02-18T00:00:00.000Z',
};

describe('TaskForm', () => {
it('accepts multi-character typing across fields and submits values', async () => {
const onSubmit = vi.fn();

render(
<TaskForm
mode="create"
tasks={[taskA]}
onSubmit={onSubmit}
/>
);

fireEvent.change(screen.getByPlaceholderText('Task title'), { target: { value: 'Write tests' } });
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), {
target: { value: 'Cover critical user flows' },
});
fireEvent.change(screen.getByRole('spinbutton', { name: 'Priority' }), { target: { value: '12' } });
fireEvent.change(screen.getByRole('combobox', { name: 'Parent task' }), { target: { value: 'task-a' } });
fireEvent.change(screen.getByPlaceholderText('Optional agent profile hint'), {
target: { value: 'frontend-specialist' },
});

fireEvent.click(screen.getByRole('button', { name: 'Create Task' }));

expect(onSubmit).toHaveBeenCalledWith({
title: 'Write tests',
description: 'Cover critical user flows',
priority: 12,
parentTaskId: 'task-a',
agentProfileHint: 'frontend-specialist',
});
});

it('preserves input while typing multiple consecutive characters', () => {
render(
<TaskForm
mode="create"
tasks={[]}
onSubmit={vi.fn()}
/>
);

const titleInput = screen.getByPlaceholderText('Task title') as HTMLInputElement;
const descriptionInput = screen.getByRole('textbox', { name: 'Description' }) as HTMLTextAreaElement;
const hintInput = screen.getByPlaceholderText('Optional agent profile hint') as HTMLInputElement;

fireEvent.change(titleInput, { target: { value: 'a' } });
fireEvent.change(titleInput, { target: { value: 'ab' } });
fireEvent.change(titleInput, { target: { value: 'abc' } });

fireEvent.change(descriptionInput, { target: { value: 'x' } });
fireEvent.change(descriptionInput, { target: { value: 'xy' } });
fireEvent.change(descriptionInput, { target: { value: 'xyz' } });

fireEvent.change(hintInput, { target: { value: 'm' } });
fireEvent.change(hintInput, { target: { value: 'mo' } });
fireEvent.change(hintInput, { target: { value: 'mod' } });

expect(titleInput.value).toBe('abc');
expect(descriptionInput.value).toBe('xyz');
expect(hintInput.value).toBe('mod');
});
});
33 changes: 33 additions & 0 deletions apps/web/tests/unit/pages/project.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,37 @@ describe('Project page', () => {
});
});
});

it('supports multi-character typing across new-task form fields', async () => {
renderProjectPage();

fireEvent.click(await screen.findByRole('button', { name: 'New task' }));

const titleInput = screen.getByPlaceholderText('Task title');
fireEvent.change(titleInput, { target: { value: 'W' } });
fireEvent.change(titleInput, { target: { value: 'Wr' } });
fireEvent.change(titleInput, { target: { value: 'Write docs' } });

fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), {
target: { value: 'Document edge cases for typing flows' },
});
fireEvent.change(screen.getByRole('spinbutton', { name: 'Priority' }), {
target: { value: '11' },
});
fireEvent.change(screen.getByPlaceholderText('Optional agent profile hint'), {
target: { value: 'qa-reviewer' },
});

fireEvent.click(screen.getByRole('button', { name: 'Create Task' }));

await waitFor(() => {
expect(mocks.createProjectTask).toHaveBeenCalledWith('proj-1', {
title: 'Write docs',
description: 'Document edge cases for typing flows',
priority: 11,
parentTaskId: undefined,
agentProfileHint: 'qa-reviewer',
});
});
});
});
Loading