Skip to content

Commit 341c14a

Browse files
committed
feat(quick-add): show live parse preview while typing in capture inputs
1 parent 40d7afb commit 341c14a

18 files changed

Lines changed: 956 additions & 15 deletions
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { useTaskStore } from '@mindwtr/core';
4+
5+
import { LanguageProvider } from '../contexts/language-context';
6+
import { QuickAddModal } from './QuickAddModal';
7+
8+
const coreSpies = vi.hoisted(() => ({
9+
parseQuickAdd: vi.fn(),
10+
}));
11+
12+
// The preview and the submit path have to run ONE parse configuration. Spying
13+
// on the shared entry point is the only way to prove they do: a preview built
14+
// from a second, hand-rolled options bag would still render plausible chips.
15+
vi.mock('@mindwtr/core', async () => {
16+
const actual = await vi.importActual<typeof import('@mindwtr/core')>('@mindwtr/core');
17+
coreSpies.parseQuickAdd.mockImplementation(actual.parseQuickAdd);
18+
return { ...actual, parseQuickAdd: coreSpies.parseQuickAdd };
19+
});
20+
21+
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(async () => false) }));
22+
vi.mock('@tauri-apps/api/event', () => ({ emitTo: vi.fn(async () => undefined), listen: vi.fn(async () => () => undefined) }));
23+
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ hide: vi.fn(async () => undefined) }) }));
24+
vi.mock('@tauri-apps/plugin-fs', () => ({
25+
BaseDirectory: { Data: 'Data' },
26+
mkdir: vi.fn(async () => undefined),
27+
readFile: vi.fn(async () => new Uint8Array()),
28+
remove: vi.fn(async () => undefined),
29+
writeFile: vi.fn(async () => undefined),
30+
}));
31+
vi.mock('@tauri-apps/api/path', () => ({
32+
dataDir: vi.fn(async () => '/data'),
33+
join: vi.fn(async (...parts: string[]) => parts.join('/')),
34+
}));
35+
36+
const DRAFT = 'call mom @errands #family /due:tomorrow';
37+
38+
const initialTaskState = useTaskStore.getState();
39+
const addTask = vi.fn(async () => ({ success: true, id: 'task-id' }));
40+
41+
const openModalWithDraft = async () => {
42+
render(
43+
<LanguageProvider>
44+
<QuickAddModal />
45+
</LanguageProvider>
46+
);
47+
await act(async () => {
48+
window.dispatchEvent(new CustomEvent('mindwtr:quick-add', { detail: {} }));
49+
await Promise.resolve();
50+
});
51+
const input = screen.getByPlaceholderText('Add Task');
52+
await act(async () => {
53+
fireEvent.change(input, { target: { value: DRAFT } });
54+
await Promise.resolve();
55+
});
56+
};
57+
58+
beforeEach(() => {
59+
coreSpies.parseQuickAdd.mockClear();
60+
addTask.mockClear();
61+
act(() => {
62+
useTaskStore.setState(initialTaskState, true);
63+
useTaskStore.setState((state) => ({
64+
...state,
65+
_allProjects: [],
66+
_allAreas: [],
67+
addTask,
68+
tasks: [
69+
{ id: 'seed', title: 'seed', status: 'inbox', contexts: ['@errands'], tags: ['#family'] },
70+
] as never,
71+
}));
72+
});
73+
});
74+
75+
describe('QuickAddModal live preview', () => {
76+
it('shows what the parser found in the draft', async () => {
77+
await openModalWithDraft();
78+
79+
const preview = screen.getByTestId('quick-add-preview');
80+
expect(preview).toHaveTextContent('@errands');
81+
expect(preview).toHaveTextContent('#family');
82+
// The resolved due date, not the phrase that produced it.
83+
expect(preview).toHaveTextContent('Due Date');
84+
expect(preview).not.toHaveTextContent('/due:tomorrow');
85+
});
86+
87+
it('parses the preview with the same input and options the save uses', async () => {
88+
await openModalWithDraft();
89+
90+
const previewCalls = coreSpies.parseQuickAdd.mock.calls.length;
91+
expect(previewCalls).toBeGreaterThan(0);
92+
const previewCall = coreSpies.parseQuickAdd.mock.calls[previewCalls - 1];
93+
94+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
95+
await waitFor(() => expect(addTask).toHaveBeenCalled());
96+
97+
const submitCall = coreSpies.parseQuickAdd.mock.calls[previewCalls];
98+
expect(submitCall).toBeDefined();
99+
// input, projects, areas and the options bag: same values, and the bag
100+
// is literally the same object the preview memo read.
101+
expect(submitCall[0]).toBe(previewCall[0]);
102+
expect(submitCall[1]).toBe(previewCall[1]);
103+
expect(submitCall[3]).toBe(previewCall[3]);
104+
expect(submitCall[4]).toBe(previewCall[4]);
105+
});
106+
107+
it('warns about an invalid date command instead of failing silently on save', async () => {
108+
render(
109+
<LanguageProvider>
110+
<QuickAddModal />
111+
</LanguageProvider>
112+
);
113+
await act(async () => {
114+
window.dispatchEvent(new CustomEvent('mindwtr:quick-add', { detail: {} }));
115+
await Promise.resolve();
116+
});
117+
await act(async () => {
118+
fireEvent.change(screen.getByPlaceholderText('Add Task'), { target: { value: 'call mom /due:notaday' } });
119+
await Promise.resolve();
120+
});
121+
122+
expect(screen.getByTestId('quick-add-preview')).toHaveTextContent('/due:notaday');
123+
});
124+
});

apps/desktop/src/components/QuickAddModal.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
findSelectableProjectByTitleAndArea,
1212
getQuickAddProjectInitialProps,
1313
buildQuickAddParseOptions,
14+
buildQuickAddPreviewEntries,
1415
parseQuickAdd,
1516
normalizeFocusTaskLimit,
1617
getDefaultTaskAreaMode,
@@ -56,6 +57,7 @@ import { consumeQuickAddPending, hideQuickAddWindow } from '../lib/quick-add-win
5657
import { TaskInput } from './Task/TaskInput';
5758
import { AreaSelector } from './ui/AreaSelector';
5859
import { QuickAddSyntaxHint } from './ui/QuickAddSyntaxHint';
60+
import { QuickAddPreview } from './QuickAddPreview';
5961
import { FocusStarIcon } from './FocusStarIcon';
6062

6163
// Relative to the managed data dir (portable-aware, #855).
@@ -212,6 +214,11 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
212214
() => parseQuickAdd(value, projects, new Date(), areas, quickAddParseOptions),
213215
[value, projects, areas, quickAddParseOptions],
214216
);
217+
// Same parse object the submit path uses, shaped for display only.
218+
const previewEntries = useMemo(
219+
() => buildQuickAddPreviewEntries(parsedInput, { t, projects, areas, rawInput: value }),
220+
[areas, parsedInput, projects, t, value],
221+
);
215222
const hasProjectOverride = Boolean(initialProps?.projectId || parsedInput.props.projectId || parsedInput.projectTitle);
216223
const showAreaSelector = !hasProjectOverride;
217224
const isPastingImage = pastingImageCount > 0;
@@ -832,6 +839,11 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
832839
let currentProjects = projects;
833840
let currentAreas = areas;
834841
if (standaloneWindow) {
842+
// The standalone window re-parses against projects/areas fetched
843+
// here, which can be fresher than the ones the preview strip
844+
// rendered from — the preview may lag by whatever this fetch pulls
845+
// in. Accepted: the submit deciding on fresher data is the right
846+
// direction, and the window closes on save.
835847
await refreshStandaloneData().catch((error) => reportError('Failed to refresh quick add data', error));
836848
const currentState = useTaskStore.getState();
837849
currentProjects = currentState.projects;
@@ -1094,6 +1106,7 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
10941106
<FocusStarIcon filled={focusNewTask} className="h-[18px] w-[18px]" />
10951107
</button>
10961108
</div>
1109+
<QuickAddPreview entries={previewEntries} />
10971110
{isPastingImage ? (
10981111
<p className="text-xs text-muted-foreground">
10991112
{tFallback(t, 'quickAdd.pastedImageSaving', 'Attaching image...')}
@@ -1133,11 +1146,6 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
11331146
<QuickAddSyntaxHint text={t('quickAdd.help')} />
11341147
</p>
11351148
</details>
1136-
{parsedInput.invalidDateCommands && parsedInput.invalidDateCommands.length > 0 ? (
1137-
<p className="text-xs text-destructive">
1138-
{t('quickAdd.invalidDateCommand')}: {parsedInput.invalidDateCommands.join(', ')}
1139-
</p>
1140-
) : null}
11411149
{scheduledLabel && (
11421150
<p className="text-xs text-muted-foreground">
11431151
{t('calendar.scheduleAction')}: {scheduledLabel}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import type { QuickAddPreviewEntry } from '@mindwtr/core';
4+
5+
import { QuickAddPreview } from './QuickAddPreview';
6+
7+
const entry = (id: string, overrides: Partial<QuickAddPreviewEntry> = {}): QuickAddPreviewEntry => ({
8+
id,
9+
kind: 'context',
10+
value: id,
11+
tone: 'default',
12+
...overrides,
13+
});
14+
15+
describe('QuickAddPreview', () => {
16+
it('stays a polite live region even with nothing to show', () => {
17+
render(<QuickAddPreview entries={[]} />);
18+
const region = screen.getByTestId('quick-add-preview');
19+
expect(region).toHaveAttribute('aria-live', 'polite');
20+
expect(region).toBeEmptyDOMElement();
21+
});
22+
23+
it('renders a chip per entry with its label', () => {
24+
render(<QuickAddPreview entries={[
25+
entry('due', { kind: 'due', label: 'Due Date', value: 'Aug 12, 2026, 5:00 PM' }),
26+
entry('@errands'),
27+
]} />);
28+
expect(screen.getByText('Due Date')).toBeInTheDocument();
29+
expect(screen.getByText('Aug 12, 2026, 5:00 PM')).toBeInTheDocument();
30+
expect(screen.getByText('@errands')).toBeInTheDocument();
31+
});
32+
33+
it('collapses the tail into a count once the strip would grow past two rows', () => {
34+
const entries = Array.from({ length: 11 }, (_, index) => entry(`#tag${index}`));
35+
render(<QuickAddPreview entries={entries} />);
36+
expect(screen.getByText('#tag7')).toBeInTheDocument();
37+
expect(screen.queryByText('#tag8')).not.toBeInTheDocument();
38+
expect(screen.getByText('+3')).toBeInTheDocument();
39+
});
40+
41+
it('marks warnings apart from ordinary chips', () => {
42+
render(<QuickAddPreview entries={[
43+
entry('warning:/due:nope', { kind: 'warning', label: 'Invalid date command', value: '/due:nope', tone: 'warning' }),
44+
]} />);
45+
expect(screen.getByText('/due:nope').parentElement?.className).toContain('destructive');
46+
});
47+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { QuickAddPreviewEntry } from '@mindwtr/core';
2+
3+
import { cn } from '../lib/utils';
4+
5+
// Past this the strip wraps into a third row and starts pushing the surface
6+
// around more than it informs; the rest collapse into a count.
7+
const MAX_VISIBLE_ENTRIES = 8;
8+
9+
type QuickAddPreviewProps = {
10+
entries: QuickAddPreviewEntry[];
11+
className?: string;
12+
};
13+
14+
/**
15+
* Passive read-out of what quick-add parsing found in the current draft. Never
16+
* interactive: it takes no focus, offers nothing to click, and disappears when
17+
* the draft is a plain title. The polite live region gives screen-reader users
18+
* the same feedback sighted users get from the chips appearing.
19+
*/
20+
export function QuickAddPreview({ entries, className }: QuickAddPreviewProps) {
21+
const visible = entries.slice(0, MAX_VISIBLE_ENTRIES);
22+
const overflow = entries.length - visible.length;
23+
24+
// The region renders even while empty (a bare flex row is zero height): a
25+
// live region has to be in the accessibility tree before its content
26+
// changes, or the first announcement is dropped.
27+
return (
28+
<div
29+
role="status"
30+
aria-live="polite"
31+
data-testid="quick-add-preview"
32+
className={cn('flex flex-wrap items-center gap-1 text-[11px] leading-4', className)}
33+
>
34+
{visible.map((entry) => (
35+
<span
36+
key={entry.id}
37+
className={cn(
38+
'inline-flex max-w-full items-baseline gap-1 rounded-full border px-2 py-0.5',
39+
entry.tone === 'warning'
40+
? 'border-destructive/40 bg-destructive/10 text-destructive'
41+
: 'border-border bg-muted/40 text-muted-foreground',
42+
)}
43+
>
44+
{entry.label ? <span className="shrink-0">{entry.label}</span> : null}
45+
<span className={cn('truncate font-medium', entry.tone === 'warning' ? '' : 'text-foreground/80')}>
46+
{entry.value}
47+
</span>
48+
</span>
49+
))}
50+
{overflow > 0 ? (
51+
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-muted-foreground">
52+
+{overflow}
53+
</span>
54+
) : null}
55+
</div>
56+
);
57+
}

apps/desktop/src/components/views/ListView.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ describe('ListView', () => {
114114
expect(queryByText(/Quick add supports/)).not.toBeInTheDocument();
115115
});
116116

117+
it('trades the syntax hint for a live read-out of what the draft parses to', () => {
118+
const { getByPlaceholderText, getByTestId, queryByText } = renderListView('inbox', 'Inbox');
119+
120+
act(() => {
121+
fireEvent.change(getByPlaceholderText(/Add Task/i), {
122+
target: { value: 'call mom @phone #family' },
123+
});
124+
});
125+
126+
const preview = getByTestId('quick-add-preview');
127+
expect(preview).toHaveTextContent('@phone');
128+
expect(preview).toHaveTextContent('#family');
129+
expect(queryByText('Try: Call mom /due:tomorrow 5pm @phone #family')).not.toBeInTheDocument();
130+
});
131+
117132
it('keeps Mind Sweep open when the first capture populates an empty inbox', async () => {
118133
const addTask = vi.fn(async (title: string, initialProps?: Partial<Task>) => {
119134
const task = makeTask('captured', {

apps/desktop/src/components/views/ListView.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
33
import { AlertTriangle, Folder, HelpCircle } from 'lucide-react';
44
import { buildProjectOrderMap,
55
buildQuickAddParseOptions,
6+
buildQuickAddPreviewEntries,
67
compareTasksByProjectThenOrder,
78
createTaskFilterPredicate,
89
DEFAULT_AREA_COLOR,
@@ -33,6 +34,7 @@ import { BulkSelectionToolbar } from './list/BulkSelectionToolbar';
3334
import { ListBulkActions } from './list/ListBulkActions';
3435
import { ListFiltersPanel } from './list/ListFiltersPanel';
3536
import { ListQuickAdd } from './list/ListQuickAdd';
37+
import { QuickAddPreview } from '../QuickAddPreview';
3638
import { PromptModal } from '../PromptModal';
3739
import { TokenPickerModal } from '../TokenPickerModal';
3840
import { InboxProcessor } from './InboxProcessor';
@@ -782,6 +784,15 @@ export const ListView = memo(function ListView({ title, statusFilter }: ListView
782784
const isNextView = statusFilter === 'next';
783785
const isWaitingView = statusFilter === 'waiting';
784786
const showQuickAdd = isInbox;
787+
// Live parse of the draft, with the options handleAddTask submits with, so
788+
// the strip can never claim something the save would not do.
789+
const quickAddPreviewEntries = useMemo(() => {
790+
if (!showQuickAdd || !newTaskTitle.trim()) return [];
791+
return buildQuickAddPreviewEntries(
792+
parseQuickAdd(newTaskTitle, projects, new Date(), areas, quickAddParseOptions),
793+
{ t, projects, areas, rawInput: newTaskTitle },
794+
);
795+
}, [areas, newTaskTitle, projects, quickAddParseOptions, showQuickAdd, t]);
785796
const priorityOptions = PRIORITY_FILTER_OPTIONS;
786797
const timeEstimateOptions = TIME_ESTIMATE_FILTER_OPTIONS;
787798
const formatEstimate = (value: TimeEstimate) => formatTimeEstimateLabel(value, { t });
@@ -1174,9 +1185,15 @@ export const ListView = memo(function ListView({ title, statusFilter }: ListView
11741185
{!isProcessing && (
11751186
<div className="mt-1 space-y-1 text-xs text-muted-foreground">
11761187
<div className="flex min-w-0 items-center gap-1.5">
1177-
<span className="min-w-0 truncate">
1178-
{t('quickAdd.inlineHint')}
1179-
</span>
1188+
{/* The preview takes the syntax hint's row rather than adding
1189+
one: with a draft to describe it is the better use of the
1190+
space, and the list below never shifts. */}
1191+
<QuickAddPreview entries={quickAddPreviewEntries} className="min-w-0" />
1192+
{quickAddPreviewEntries.length === 0 ? (
1193+
<span className="min-w-0 truncate">
1194+
{t('quickAdd.inlineHint')}
1195+
</span>
1196+
) : null}
11801197
<button
11811198
type="button"
11821199
onClick={() => setQuickAddSyntaxOpen((open) => !open)}

0 commit comments

Comments
 (0)