feat(web): quick capture keeps your place (TASK-3590) (#2067)

Quick capture used to open every item it created, so jotting down an
interruption meant a context switch. Now "Add" saves the item, clears the
title and keeps focus for the next capture, and a toast names the new ref
with an Open action. "Add and open" (Ctrl/Cmd+Enter, or the mobile
sheet's secondary button) keeps the old behaviour. Applies to both the
desktop sidebar dialog and the mobile capture sheet.

The mobile sheet clears only the text it sent, so anything typed while
the create is in flight is kept. e2e/bug-3473 needs write-then-open, so
it now uses Ctrl/Cmd+Enter.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
This commit is contained in:
xarmian
2026-10-11 13:45:45 -04:00
committed by GitHub
parent ebb2936272
commit c62b2b80da
7 changed files with 398 additions and 14 deletions
+3 -2
View File
@@ -53,10 +53,11 @@ test.describe('write, then open at once (BUG-3473)', () => {
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/docs\/items$/.test(r.url()) && r.ok(),
);
await dialog.locator('textarea.quick-add-input').press('Enter');
// Add and open (TASK-3590: plain Enter adds and stays on the list).
await dialog.locator('textarea.quick-add-input').press('ControlOrMeta+Enter');
await created;
await expect(dialog).toBeHidden();
// The quick-add opens the new item's page; go straight back to the list.
// Add and open lands on the new item's page; go straight back to the list.
await expect(page).toHaveURL(/\/docs\/[^/?]+$/);
await page.goBack();
@@ -0,0 +1,85 @@
import { test, expect } from './fixtures';
import { browserLogin } from './lib/collab-helpers';
/**
* TASK-3590: quick capture keeps your place. Adding an item used to open it,
* so jotting down an interruption was a context switch. "Add" now saves,
* stays on the current screen ready for the next capture, and confirms with a
* toast whose Open action goes to the item. "Add and open" (Ctrl/Cmd+Enter,
* or the mobile sheet's secondary button) keeps the old behaviour.
*/
test.describe('TASK-3590: quick capture keeps your place', () => {
test('desktop sidebar: Enter adds twice and stays; the toast opens the item; Ctrl/Cmd+Enter opens', async ({ page, fixture }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'the sidebar quick-add');
const stamp = `qc${Date.now().toString(36)}`;
await browserLogin(page);
const listUrl = `/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks`;
await page.goto(listUrl);
const add = page.locator('button.nav-quick-add[title="New Task"]');
await add.hover({ force: true });
await add.click({ force: true });
const dialog = page.locator('.quick-add-modal');
const input = dialog.locator('textarea.quick-add-input');
await expect(dialog).toBeVisible();
for (const n of [1, 2]) {
await input.fill(`${stamp} stay ${n}`);
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/tasks\/items$/.test(new URL(r.url()).pathname) && r.ok(),
);
await input.press('Enter');
await created;
await expect(input).toHaveValue('');
await expect(input).toBeFocused();
}
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${listUrl}(\\?.*)?$`));
const toast = page.locator('.toast', { hasText: /Added TASK-\d+/ }).last();
await expect(toast).toBeVisible();
await input.press('Escape');
await expect(dialog).toBeHidden();
await toast.getByRole('button', { name: 'Open' }).click();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} stay 2`).first()).toBeVisible();
// Add and open.
await page.goto(listUrl);
await add.hover({ force: true });
await add.click({ force: true });
await input.fill(`${stamp} open`);
await input.press('ControlOrMeta+Enter');
await expect(dialog).toBeHidden();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} open`).first()).toBeVisible();
});
test('mobile sheet: Add stays open for the next capture; Add and open navigates', async ({ page, fixture }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'the mobile capture sheet');
const stamp = `qm${Date.now().toString(36)}`;
await browserLogin(page);
const listUrl = `/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks`;
await page.goto(listUrl);
await page.getByRole('button', { name: 'Quick capture' }).click();
const sheet = page.getByRole('dialog', { name: 'Quick capture' });
const title = sheet.getByRole('textbox', { name: 'New item title' });
await expect(title).toBeVisible();
await title.fill(`${stamp} stay`);
const created = page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/collections\/tasks\/items$/.test(new URL(r.url()).pathname) && r.ok(),
);
await sheet.getByRole('button', { name: 'Add', exact: true }).click();
await created;
await expect(title).toHaveValue('');
await expect(sheet).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${listUrl}(\\?.*)?$`));
await expect(page.locator('.toast', { hasText: /Added TASK-\d+/ }).last()).toBeVisible();
await title.fill(`${stamp} open`);
await sheet.getByRole('button', { name: 'Add and open' }).click();
await expect(page).toHaveURL(/\/tasks\/TASK-\d+/);
await expect(page.getByText(`${stamp} open`).first()).toBeVisible();
});
});
@@ -1,11 +1,12 @@
<!--
QuickCaptureSheet — the mobile BottomNav's center "+" action (PLAN-1694,
TASK-1698). A self-contained capture flow: pick a collection, type a title,
create. Deliberately does NOT reuse the Sidebar's quickAdd overlay
create. "Add" stays on the current screen and readies the sheet for the
next capture; "Add and open" goes to the new item (TASK-3590). Deliberately does NOT reuse the Sidebar's quickAdd overlay
(uiStore.requestQuickAdd) — that path only works while the Sidebar is
mounted and renders a desktop-ish overlay (Codex review of PLAN-1694). The
create logic mirrors Sidebar.svelte's submitQuickAdd so behaviour (default
status, content template, navigate to the new item) stays consistent.
status, content template, add vs add-and-open) stays consistent.
Presents as a DockedSheet (anchored above the bottom nav) like the
Workspace/You sheets, so the nav stays visible/tappable, the + slot stays
@@ -22,6 +23,7 @@
import { uiStore } from '$lib/stores/ui.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import { titleLimitError } from '$lib/items/titleLimit';
import { announceCaptured } from '$lib/items/quickCapture';
import { parseSettings, itemUrlId, isAgentCollection } from '$lib/types';
import { createDefaultFields } from '$lib/collections/createDefaults';
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
@@ -52,6 +54,7 @@
let selectedSlug = $state('');
let title = $state('');
let submitting = $state(false);
let titleEl: HTMLTextAreaElement | undefined = $state();
// Default to tasks (or the first collection) when the sheet opens. Also
// re-default if the remembered slug is no longer a valid collection (e.g.
@@ -69,7 +72,7 @@
onclose();
}
async function submit() {
async function submit(openAfter = false) {
if (!wsSlug || !selectedSlug || !title.trim() || submitting) return;
const coll = collections.find((c) => c.slug === selectedSlug);
if (!coll) return;
@@ -91,10 +94,19 @@
fields: JSON.stringify(defaultFields),
source: 'web'
});
const url = `${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`;
if (!openAfter) {
announceCaptured(item, url);
// Clear only what was sent: text typed while the request was
// in flight is the next capture, not this one.
if (title.trim() === t) title = '';
if (open) requestAnimationFrame(() => titleEl?.focus());
return;
}
title = '';
onclose();
uiStore.onNavigate();
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
goto(url);
} catch (err: any) {
if (isPlanLimitError(err)) {
showPlanLimitToast(err);
@@ -113,7 +125,7 @@
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
submit(e.metaKey || e.ctrlKey);
}
}
</script>
@@ -135,6 +147,7 @@
<textarea aria-label="New item title"
class="capture-title"
bind:value={title}
bind:this={titleEl}
use:autofocus
{onkeydown}
placeholder="What needs doing?"
@@ -142,10 +155,16 @@
></textarea>
<div class="capture-actions">
<button class="capture-cancel" type="button" onclick={close}>Cancel</button>
<button
class="capture-open"
type="button"
onclick={() => submit(true)}
disabled={!title.trim() || submitting}
>Add and open</button>
<button
class="capture-submit"
type="button"
onclick={submit}
onclick={() => submit()}
disabled={!title.trim() || submitting}
>
{submitting ? 'Adding…' : 'Add'}
@@ -203,6 +222,7 @@
gap: var(--space-2);
}
.capture-cancel,
.capture-open,
.capture-submit {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
@@ -220,6 +240,12 @@
color: #fff;
border-color: var(--accent-blue);
}
/* TASK-3590: the secondary "Add and open". */
.capture-open {
background: var(--bg-primary);
color: var(--text-primary);
}
.capture-open:disabled,
.capture-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -0,0 +1,109 @@
// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`).
//
// TASK-3590: the mobile quick-capture sheet keeps the user's place. "Add"
// creates the item, stays open with an empty title for the next capture and
// confirms with a toast that links to the item. "Add and open" closes and
// navigates as the sheet always did.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, cleanup, fireEvent, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
const created = vi.hoisted(() => ({ n: 0 }));
vi.mock('$lib/api/client', () => ({
api: {
items: {
create: vi.fn(async (_ws: string, _coll: string, body: { title: string }) => {
created.n++;
return { id: `i${created.n}`, slug: `s${created.n}`, title: body.title, collection_prefix: 'TASK', item_number: created.n };
}),
},
},
isPlanLimitError: () => false,
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/stores/workspace.svelte', () => ({
workspaceStore: { canEditCollection: () => true },
}));
vi.mock('$lib/stores/collections.svelte', async () => await import('./sidebarQuickAdd.fixture.svelte'));
import QuickCaptureSheet from './QuickCaptureSheet.svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { toastStore } from '$lib/stores/toast.svelte';
function title(): HTMLTextAreaElement {
return screen.getByRole('textbox', { name: 'New item title' }) as HTMLTextAreaElement;
}
async function settle() {
for (let i = 0; i < 4; i++) await tick();
}
function mount() {
const onclose = vi.fn();
render(QuickCaptureSheet, { open: true, onclose, wsSlug: 'ws', wsPrefix: '/u/ws' });
return onclose;
}
beforeEach(() => {
cleanup();
created.n = 0;
vi.mocked(goto).mockClear();
vi.mocked(api.items.create).mockClear();
for (const t of [...toastStore.toasts]) toastStore.dismiss(t.id);
});
describe('QuickCaptureSheet keeps your place (TASK-3590)', () => {
it('Add creates, stays open with an empty title and confirms with a linked toast', async () => {
const onclose = mount();
await tick();
await fireEvent.input(title(), { target: { value: 'jot this down' } });
await fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await settle();
expect(api.items.create).toHaveBeenCalledTimes(1);
expect(goto, 'Add navigated away').not.toHaveBeenCalled();
expect(onclose, 'Add closed the sheet').not.toHaveBeenCalled();
expect(title().value).toBe('');
const toast = toastStore.toasts.at(-1);
expect(toast?.message).toBe('Added TASK-1');
expect(toast?.link).toBe('/u/ws/tasks/TASK-1?new=1');
expect(toast?.action?.label).toBe('Open');
});
it('two Adds in a row create two items without navigating', async () => {
mount();
await tick();
for (const t of ['first', 'second']) {
await fireEvent.input(title(), { target: { value: t } });
await fireEvent.keyDown(title(), { key: 'Enter' });
await settle();
}
expect(vi.mocked(api.items.create).mock.calls.map((c) => (c[2] as { title: string }).title)).toEqual(['first', 'second']);
expect(goto).not.toHaveBeenCalled();
});
it('Add and open closes the sheet and navigates to the new item', async () => {
const onclose = mount();
await tick();
await fireEvent.input(title(), { target: { value: 'open me' } });
await fireEvent.click(screen.getByRole('button', { name: 'Add and open' }));
await settle();
expect(onclose).toHaveBeenCalled();
expect(goto).toHaveBeenCalledWith('/u/ws/tasks/TASK-1?new=1');
});
it('text typed while the create was in flight is kept, not cleared', async () => {
let resolve!: (v: unknown) => void;
vi.mocked(api.items.create).mockImplementationOnce(() => new Promise((r) => (resolve = r)));
mount();
await tick();
await fireEvent.input(title(), { target: { value: 'first' } });
await fireEvent.keyDown(title(), { key: 'Enter' });
await fireEvent.input(title(), { target: { value: 'next thought' } });
resolve({ id: 'i1', slug: 's1', title: 'first', collection_prefix: 'TASK', item_number: 1 });
await settle();
expect(title().value).toBe('next thought');
});
});
@@ -0,0 +1,120 @@
// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`).
//
// TASK-3590: sidebar quick-add keeps the user's place. Plain Enter (and the
// Add button) creates the item, clears the input for the next capture and
// confirms with a toast that links to the item. Ctrl/Cmd+Enter is "add and
// open", which navigates as quick-add always did.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/svelte';
import { tick } from 'svelte';
const created = vi.hoisted(() => ({ n: 0 }));
vi.mock('$lib/api/client', () => ({
api: {
health: vi.fn(async () => ({ version: 'dev', commit: 'abcdef0' })),
collections: { list: vi.fn(async () => []), update: vi.fn() },
items: {
create: vi.fn(async (_ws: string, _coll: string, body: { title: string }) => {
created.n++;
return { id: `i${created.n}`, slug: `s${created.n}`, title: body.title, collection_prefix: 'TASK', item_number: created.n };
}),
},
},
isPlanLimitError: () => false,
planLimitMessage: () => '',
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), afterNavigate: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$lib/stores/workspace.svelte', () => ({
workspaceStore: {
current: { slug: 'ws', owner_username: 'u', is_guest: false },
canEditCollection: () => true,
membershipKnown: true
},
}));
vi.mock('$lib/stores/collections.svelte', async () => await import('./sidebarQuickAdd.fixture.svelte'));
import Sidebar from './Sidebar.svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { uiStore } from '$lib/stores/ui.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
function input(): HTMLTextAreaElement {
const el = document.querySelector<HTMLTextAreaElement>('.quick-add-modal textarea.quick-add-input');
if (!el) throw new Error('quick-add dialog is not open');
return el;
}
async function open() {
uiStore.requestQuickAdd('tasks');
await tick();
await tick();
}
async function settle() {
for (let i = 0; i < 4; i++) await tick();
}
beforeEach(() => {
cleanup();
created.n = 0;
vi.mocked(goto).mockClear();
vi.mocked(api.items.create).mockClear();
for (const t of [...toastStore.toasts]) toastStore.dismiss(t.id);
});
describe('Sidebar quick-add keeps your place (TASK-3590)', () => {
it('Enter adds, stays put, clears the input and confirms with a linked toast', async () => {
render(Sidebar);
await open();
await fireEvent.input(input(), { target: { value: 'jot this down' } });
await fireEvent.keyDown(input(), { key: 'Enter' });
await settle();
expect(api.items.create).toHaveBeenCalledTimes(1);
expect(goto, 'Add navigated away').not.toHaveBeenCalled();
expect(document.querySelector('.quick-add-modal'), 'Add closed the dialog').not.toBeNull();
expect(input().value).toBe('');
const toast = toastStore.toasts.at(-1);
expect(toast?.message).toBe('Added TASK-1');
expect(toast?.link).toBe('/u/ws/tasks/TASK-1?new=1');
expect(toast?.action?.label).toBe('Open');
toast!.action!.onAction();
expect(goto).toHaveBeenCalledWith('/u/ws/tasks/TASK-1?new=1');
});
it('two Adds in a row create two items without navigating', async () => {
render(Sidebar);
await open();
for (const t of ['first', 'second']) {
await fireEvent.input(input(), { target: { value: t } });
await fireEvent.keyDown(input(), { key: 'Enter' });
await settle();
}
expect(vi.mocked(api.items.create).mock.calls.map((c) => (c[2] as { title: string }).title)).toEqual(['first', 'second']);
expect(goto).not.toHaveBeenCalled();
});
it('the Add button stays too', async () => {
render(Sidebar);
await open();
await fireEvent.input(input(), { target: { value: 'by button' } });
await fireEvent.click(document.querySelector<HTMLButtonElement>('.quick-add-btn')!);
await settle();
expect(api.items.create).toHaveBeenCalledTimes(1);
expect(goto).not.toHaveBeenCalled();
});
for (const mod of ['ctrlKey', 'metaKey'] as const) {
it(`${mod}+Enter adds and opens the new item`, async () => {
render(Sidebar);
await open();
await fireEvent.input(input(), { target: { value: 'open me' } });
await fireEvent.keyDown(input(), { key: 'Enter', [mod]: true });
await settle();
expect(goto).toHaveBeenCalledWith('/u/ws/tasks/TASK-1?new=1');
expect(document.querySelector('.quick-add-modal'), 'add and open left the dialog up').toBeNull();
});
}
});
+23 -6
View File
@@ -18,6 +18,7 @@
import { getActiveKey } from '$lib/nav/destinations';
import type { Collection } from '$lib/types';
import { toastStore } from '$lib/stores/toast.svelte';
import { announceCaptured } from '$lib/items/quickCapture';
import { titleLimitError } from '$lib/items/titleLimit';
import NotificationPanel from '$lib/components/common/NotificationPanel.svelte';
import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte';
@@ -196,7 +197,10 @@
pickerOpen = false;
}
async function submitQuickAdd() {
// `open`: Cmd/Ctrl+Enter, "add and open" (today's behaviour). Plain Enter
// adds and stays: the dialog clears and keeps focus for the next capture,
// and a toast says what was added, with an Open action (TASK-3590).
async function submitQuickAdd(open = false) {
if (!wsSlug || !quickAddCollection || !quickAddTitle.trim() || quickAddSubmitting) return;
const coll = quickAddCollection;
const title = quickAddTitle.trim();
@@ -229,11 +233,24 @@
fields: JSON.stringify(defaultFields),
source: 'web'
});
const url = `${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`;
if (!open) {
announceCaptured(item, url);
// Only the dialog this submission came from is readied for the
// next capture: one dismissed or reopened since is the user's.
if (quickAddSession === session) {
quickAddTitle = '';
quickAddError = null;
if (quickAddInputEl) quickAddInputEl.style.height = 'auto';
requestAnimationFrame(() => quickAddInputEl?.focus());
}
return;
}
// Only close the dialog this submission came from: one the user has
// since dismissed or reopened is theirs, not ours.
if (quickAddSession === session) cancelQuickAdd();
uiStore.onNavigate();
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
goto(url);
} catch (err: any) {
if (isPlanLimitError(err)) {
showPlanLimitToast(err);
@@ -252,7 +269,7 @@
function handleQuickAddKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
submitQuickAdd();
submitQuickAdd(e.metaKey || e.ctrlKey);
} else if (e.key === 'Escape') {
// preventDefault on both paths (TASK-2235): quick-add is a native
// <dialog> now, and an Escape keydown left un-prevented also fires
@@ -911,13 +928,13 @@
<p id="quick-add-error" class="quick-add-error" role="alert">{quickAddError}</p>
{/if}
<div class="quick-add-actions">
<span class="quick-add-hint">Enter to create · Esc to cancel</span>
<span class="quick-add-hint">Enter to add · {modKeyLabel('Enter')} to add and open · Esc to close</span>
<button
class="quick-add-btn"
type="button"
disabled={!quickAddTitle.trim() || quickAddSubmitting}
onclick={submitQuickAdd}
>Create</button>
onclick={() => submitQuickAdd()}
>Add</button>
</div>
</div>
{/if}
+26
View File
@@ -0,0 +1,26 @@
// Quick capture confirms in place (TASK-3590). Adding an item used to open it
// at once, so jotting down an interruption was a context switch. Now "Add"
// stays where the user is, and this toast says what was added, with an Open
// action (and the link kept in the notification tray) for when they do want
// to go there.
import { goto } from '$app/navigation';
import { toastStore } from '$lib/stores/toast.svelte';
interface Captured {
title: string;
collection_prefix?: string | null;
item_number?: number | null;
}
/** "TASK-12", or the title when the item has no ref. */
export function capturedLabel(item: Captured): string {
return item.collection_prefix && item.item_number ? `${item.collection_prefix}-${item.item_number}` : item.title;
}
/** The confirmation toast for an item added without opening it. */
export function announceCaptured(item: Captured, url: string): void {
toastStore.show(`Added ${capturedLabel(item)}`, 'success', undefined, url, {
label: 'Open',
onAction: () => goto(url),
});
}