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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Optional capture titles (Phase 2, piece D)

**Date:** 2026-06-12
**Status:** Approved

## Problem

Capturing a thought or pasting a transcript requires typing a title, even
though the LLM extraction already produces a better one. The backend already
backfills an empty title (`node.title === '' ? extraction.title`) for the
single-node and document paths — only the capture form's submit gate and the
API's title-required check block it, and the meeting/call path doesn't backfill.

## Changes

### Frontend — `src/components/capture/QuickCaptureForm.tsx`

- Submit gate (non-file modes): enable when there is a title **or** a
description, instead of requiring a title:
`(title.trim() || description.trim()) && !isBusy`. File mode unchanged.
- Relabel "Title" → "Title (optional)"; placeholder hint such as
"Leave blank — the AI will name it from your notes".

### Backend — `src/app/api/capture/route.ts`

- Relax the title-required check (currently rejects empty title when there's no
attachment): reject only when there is no attachment **and** no title **and**
no description — i.e. an entirely-empty capture. Message → "Add a title or
some content".

### Backend — `src/app/api/capture/process/route.ts` (meeting branch)

- When the parent node has an empty title, backfill it from
`meetingExtraction.meeting_title` (mirrors the single-node and document
branches, which already backfill). Spread a `titleUpdate` into the parent
node update.

## Out of scope

- Changing how single-node / document titles are backfilled (already correct).

## Testing

- `QuickCaptureForm` test: submit is enabled when a description is present and
the title is empty; disabled when both title and description are empty.
- `capture/route` validation test (if present): empty title + non-empty
description is accepted; entirely-empty capture is rejected.
- tsc 0, lint 0, full suite green.
5 changes: 4 additions & 1 deletion src/app/api/capture/process/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ export const POST = withAuth(async ({ request, user, supabase }) => {
goalContext,
);

// Store meeting extraction on parent node
// Store meeting extraction on parent node; backfill the title from the
// LLM when the capture was submitted without one.
const meetingTitleUpdate = node.title === '' ? { title: meetingExtraction.meeting_title } : {};
await supabase
.from('nodes')
.update({
...meetingTitleUpdate,
llm_extraction: meetingExtraction as unknown as Record<string, unknown>,
status: 'llm_reviewed',
})
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/capture/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ export const POST = withAuth(async ({ request, user, supabase }) => {
if (hasAttachment && !isOwnedStoragePath(attachment.storage_path, user.id)) {
return NextResponse.json({ error: 'Invalid attachment path' }, { status: 403 });
}
if (!hasAttachment && (!title || typeof title !== 'string' || title.trim().length === 0)) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
// A capture needs *something* to work with: a title, a description (the LLM
// titles it during extraction), or an attachment. Only reject when empty.
const hasTitle = typeof title === 'string' && title.trim().length > 0;
const hasDescription = typeof description === 'string' && description.trim().length > 0;
if (!hasAttachment && !hasTitle && !hasDescription) {
return NextResponse.json({ error: 'Add a title or some content' }, { status: 400 });
}

const externalLinks = external_link?.url
Expand Down
6 changes: 3 additions & 3 deletions src/components/capture/QuickCaptureForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function QuickCaptureForm({ onSubmit, isSubmitting = false, entryMode = n
const isBusy = submitPhase !== 'idle' || isSubmitting;
const canSubmit = isFileMode
? selectedFile !== null && !isUploading && !isBusy
: title.trim().length > 0 && !isBusy;
: (title.trim().length > 0 || description.trim().length > 0) && !isBusy;

const descriptionRows = entryMode === 'call' ? 10 : 5;
const descriptionPlaceholder = entryMode === 'call'
Expand Down Expand Up @@ -141,14 +141,14 @@ export function QuickCaptureForm({ onSubmit, isSubmitting = false, entryMode = n
<>
<div>
<label htmlFor="title" className="block text-xs text-cof-text-tertiary uppercase tracking-wide mb-1">
Title
Title <span className="normal-case text-cof-text-tertiary/70">(optional)</span>
</label>
<input
id="title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="What's on your mind?"
placeholder="Leave blank — the AI will name it from your notes"
className="w-full bg-cof-bg-elevated border border-cof-border rounded-lg px-3 py-2 text-sm text-cof-text-primary placeholder:text-cof-text-tertiary focus:outline-none focus:border-node-hunch focus:ring-1 focus:ring-node-hunch/20 transition-colors"
/>
</div>
Expand Down
9 changes: 8 additions & 1 deletion src/components/capture/__tests__/QuickCaptureForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vi.mock('../PersonAutocomplete', () => ({
}));

describe('QuickCaptureForm', () => {
it('disables submit when title is empty', () => {
it('disables submit when both title and description are empty', () => {
render(<QuickCaptureForm onSubmit={vi.fn()} />);
const submitButton = screen.getByRole('button', { name: /capture/i });
expect(submitButton).toBeDisabled();
Expand All @@ -25,6 +25,13 @@ describe('QuickCaptureForm', () => {
expect(submitButton).not.toBeDisabled();
});

it('enables submit with only a description (title optional)', () => {
render(<QuickCaptureForm onSubmit={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'A pasted transcript with no title' } });
const submitButton = screen.getByRole('button', { name: /^capture$/i });
expect(submitButton).not.toBeDisabled();
});

it('calls onSubmit with form data', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<QuickCaptureForm onSubmit={onSubmit} />);
Expand Down
Loading