NewTaskDialog.tsx24.1 KBView on GitHub 'use client';
/**
* NewTaskDialog — creating a task, as one modal.
*
* Replaces the inline `NewTaskCard`, which dropped a card into the column and walked three
* cmd+k steps (deal → description → date). One step at a time meant you could not see what
* you had already chosen, could not go back, and could not skip the date — every task cost
* three decisions whether or not it needed them.
*
* This is Linear's "New issue" modal: everything visible at once, title first, every property
* an optional chip you may ignore. The geometry is measured off Linear's live DOM and written
* down in `apps/mail/docs/new-task-modal-linear-spec.md` — read that before changing a number
* here. Two deliberate departures from it:
* • Type sizes use the standard Tailwind scale (18/14/12) rather than Linear's 18/15/13,
* because arbitrary px text sizes are banned in this app.
* • The primary button is our `primary` token, not Linear's indigo. Copy the shape, keep
* the brand.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Command as Cmdk } from 'cmdk';
import { format, isToday, isTomorrow } from 'date-fns';
import { CalendarClock, CircleSlash, User, X } from 'lucide-react';
import { toast } from 'sonner';
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command';
import { Breadcrumb } from '@/components/ui/breadcrumb';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { OptionPicker, PICKER_SURFACE_CLASS } from '@/components/ui/option-picker';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ConversationCompanyAvatar } from '@/modules/conversationsPage/components/ConversationCompanyAvatar';
import { createDateFuseInstance, generateDateSuggestions } from '@/modules/crm/utils/time';
import { useOpenTaskView } from '@/modules/userTasks/hooks/use-open-task-view';
import { taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
/**
* Dim the page behind the modal.
*
* Linear does not — it floats its modal on shadow alone. We do, because a task is composed
* against a board you are still reading and the scrim is what says "answer this first".
* Clicking the scrim dismisses; nothing is lost when it does (see `DRAFT_KEY`).
* Flip to `false` for Linear's shadow-only treatment.
*/
const DIM_BACKDROP = true;
/**
* A property chip. 24px pill, ring drawn as an `::after` box-shadow rather than a border so it
* never affects layout — Linear's trick, see the spec.
*
* `bg-sunken` against the modal's `bg-popover` (raised). Linear's chips are the same white as
* its modal and lean entirely on the ring, which works on one fixed light theme; ours has two,
* and on the dark card an unfilled chip disappeared into it. Raised-on-base was the other
* candidate and is the one that fails: #fffcf6 → #ffffff in light is invisible.
*/
const CHIP =
'relative flex h-6 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-sunken pl-1.5 pr-2 ' +
'text-xs font-medium text-muted-foreground transition-colors hover:text-foreground ' +
'after:pointer-events-none after:absolute after:inset-0 after:rounded-full ' +
'after:shadow-[0_0_0_0.5px_rgb(0_0_0/0.09),0_3px_6px_-2px_rgb(0_0_0/0.02),0_1px_1px_rgb(0_0_0/0.04)] ' +
'dark:after:shadow-[0_0_0_0.5px_rgb(255_255_255/0.14)]';
/** A chip carrying a value reads as foreground text; an unset one stays muted. */
const CHIP_SET = 'text-foreground';
const PICKER_ITEM =
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm aria-selected:bg-sunken';
/**
* The unsent draft, kept in localStorage.
*
* Clicking away from a half-written task must not cost anything, and must not cost a
* "discard your changes?" prompt either — that dialog is the app asking the user to clean up
* after a decision the app made. So dismissing just stores what is there; the next `c` opens
* the modal exactly as it was left. Cleared once the task is actually created.
*/
const DRAFT_KEY=[redacted];
/**
* How long to wait between closing one picker and opening the next in the Enter chain.
*
* Popovers animate in and out over 150ms. Opening the date picker on the next frame starts its
* enter animation while the deal picker is still running its exit, and the two interfere —
* the incoming layer sticks part-way through its fade and never reaches full opacity, so the
* picker is open, focused, and all but invisible. Letting the outgoing one finish first is the
* whole fix.
*/
const PICKER_HANDOFF_MS = 170;
interface StoredDraft {
title: string;
notes: string;
deal: ChosenConversation | null;
dueDate: { iso: string; label: string } | null;
groupId: string | null;
}
function readDraft(): StoredDraft | null {
try {
const raw = window.localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return null;
const d = parsed as Partial<StoredDraft>;
if (typeof d.title !== 'string' || typeof d.notes !== 'string') return null;
return {
title: d.title,
notes: d.notes,
deal: d.deal ?? null,
dueDate: d.dueDate ?? null,
groupId: d.groupId ?? null,
};
} catch {
// Private mode, disabled storage, or a shape from an older build — start clean.
return null;
}
}
function writeDraft(draft: StoredDraft | null) {
try {
if (draft === null) window.localStorage.removeItem(DRAFT_KEY);
else window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
// Storage unavailable — the draft simply doesn't survive, which is the old behaviour.
}
}
/** How a chosen date reads on the chip. Matches the picker's own Today/Tomorrow wording. */
function formatDueLabel(date: Date): string {
if (isToday(date)) return 'Today';
if (isTomorrow(date)) return 'Tomorrow';
return format(date, 'EEE, MMM d');
}
/** A draft worth restoring has something in it. */
function draftIsEmpty(d: StoredDraft): boolean {
return !d.title.trim() && !d.notes.trim() && !d.deal && !d.dueDate;
}
interface ChosenConversation {
id: string;
label: string;
/** The deal's `next_step_date` — when it is next due an update, and so the task's default. */
nextStepDate: string | null;
}
interface NewTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Pre-selected group — the column the modal was opened from. null for Misc. */
taskGroupId: string | null;
/** Called after a task is created, so the caller can refetch its list. */
onCreated: () => void;
}
export function NewTaskDialog({
open,
onOpenChange,
taskGroupId,
onCreated,
}: NewTaskDialogProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const openTaskView = useOpenTaskView();
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [deal, setDeal] = useState<ChosenConversation | null>(null);
const [dueDate, setDueDate] = useState<{ date: Date; label: string } | null>(null);
const [groupId, setGroupId] = useState<string | null>(taskGroupId);
const [dealPickerOpen, setDealPickerOpen] = useState(false);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const titleRef = useRef<HTMLTextAreaElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
const create = useMutation(trpc.userTasks.createStandaloneTask.mutationOptions());
const { data: groupsData } = useQuery({
...trpc.taskGroups.listGroups.queryOptions(listGroupsInput()),
enabled: open,
});
const groups = groupsData?.groups ?? [];
// Opening restores whatever was left behind last time; an empty (or absent) draft starts
// clean, with the group seeded from the column the modal was opened from.
useEffect(() => {
if (!open) return;
setDealPickerOpen(false);
setDatePickerOpen(false);
const draft = readDraft();
if (draft && !draftIsEmpty(draft)) {
setTitle(draft.title);
setNotes(draft.notes);
setDeal(draft.deal);
setDueDate(
draft.dueDate ? { date: new Date(draft.dueDate.iso), label: draft.dueDate.label } : null,
);
setGroupId(draft.groupId);
return;
}
setTitle('');
setNotes('');
setDeal(null);
setDueDate(null);
setGroupId(taskGroupId);
}, [open, taskGroupId]);
// Every keystroke is saved, so no dismissal path — Escape, the close button, a click on the
// scrim — needs its own handling, and none of them can ask the user to confirm a loss.
useEffect(() => {
if (!open) return;
writeDraft({
title,
notes,
deal,
dueDate: dueDate ? { iso: dueDate.date.toISOString(), label: dueDate.label } : null,
groupId,
});
}, [open, title, notes, deal, dueDate, groupId]);
/**
* Picking the deal is the middle of the Enter chain: title → deal → date.
*
* The task's due date defaults to the deal's own next step date, because a task about a deal
* is due when the deal is due an update — asking again for a date the CRM already holds is
* the kind of question software should answer for itself. The picker still opens on top of
* it, so the default is a starting point rather than a decision made for the user.
*/
const chooseDeal = (chosen: ChosenConversation | null) => {
setDeal(chosen);
if (chosen?.nextStepDate) {
const date = new Date(chosen.nextStepDate);
if (!Number.isNaN(date.getTime())) setDueDate({ date, label: formatDueLabel(date) });
}
setDealPickerOpen(false);
if (chosen) window.setTimeout(() => setDatePickerOpen(true), PICKER_HANDOFF_MS);
};
/**
* The last link in the chain. Once the date is answered the properties are done, so focus
* lands in the description — the one field left, and the only one a task might still want.
*/
const chooseDueDate = (chosen: { date: Date; label: string } | null) => {
setDueDate(chosen);
setDatePickerOpen(false);
window.setTimeout(() => notesRef.current?.focus(), PICKER_HANDOFF_MS);
};
const canSubmit = title.trim().length > 0 && !create.isPending;
const submit = () => {
if (!canSubmit) return;
create.mutate(
{
description: title.trim(),
...(notes.trim() ? { notes: notes.trim() } : {}),
...(deal ? { conversationId: deal.id } : {}),
...(groupId ? { taskGroupId: groupId } : {}),
...(dueDate ? { dueDate: dueDate.date.toISOString() } : {}),
},
{
onSuccess: (result) => {
writeDraft(null);
onCreated();
void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] });
onOpenChange(false);
// Land on the task just made: a brand-new task has no output to route to, so the
// task itself is what to show.
const id = (result as { task?: { id?: string } } | undefined)?.task?.id;
if (id) openTaskView(id);
},
onError: () => toast.error('Could not add task'),
},
);
};
const group = groups.find((g) => g.id === groupId) ?? null;
const GroupIcon = taskGroupIcon(group?.icon);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
overlayClassName={DIM_BACKDROP ? undefined : 'bg-transparent'}
className={cn(
'bg-popover top-[108px] left-1/2 block w-[750px] max-w-[calc(100vw-24px)] translate-x-[-50%] translate-y-0 sm:max-w-[calc(100vw-24px)]',
'gap-0 rounded-[21px] border-[0.5px] p-0',
'shadow-[0_9px_48px_rgb(0_0_0/0.08),0_6px_24px_rgb(0_0_0/0.10),0_1px_1px_rgb(0_0_0/0.04)]',
)}
onOpenAutoFocus={(e) => {
e.preventDefault();
titleRef.current?.focus();
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
>
{/* Header — 12px all round, breadcrumb left, actions right. */}
<div className="flex items-start p-3">
<div className="flex min-w-0 flex-1 items-center">
<Breadcrumb
className="py-0"
items={[
{
label: group?.name ?? 'Tasks',
icon: (
<GroupIcon style={group?.color ? { color: group.color } : undefined} />
),
},
{ label: <DialogTitle className="truncate text-sm font-normal">New task</DialogTitle> },
]}
/>
</div>
<div className="flex items-center gap-1.5 pl-3">
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="text-muted-foreground hover:bg-sunken hover:text-foreground flex size-[27px] shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Body — nested padding gives the text an 18px inset against the header's 12px. */}
<div className="flex px-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-1.5 px-3">
<textarea
ref={titleRef}
rows={1}
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
// Enter starts the chain: deal → date → description. The description comes
// last because it is the one field a task might not need at all.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setDealPickerOpen(true);
}
}}
placeholder="Task title"
className="placeholder:text-muted-foreground/70 text-foreground w-full resize-none bg-transparent pt-0.5 text-lg font-semibold outline-none"
/>
<textarea
ref={notesRef}
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add description…"
className="placeholder:text-muted-foreground/70 text-foreground min-h-20 w-full resize-none bg-transparent pb-3 text-sm outline-none"
/>
</div>
</div>
{/* Footer — Linear puts an attachment button in the left slot; we have no attachments,
so the properties live there instead and the row keeps its space-between shape. */}
<div className="flex items-center justify-between gap-4.5 px-3 pt-1.5 pb-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<DealChip
value={deal}
onChange={chooseDeal}
open={dealPickerOpen}
onOpenChange={setDealPickerOpen}
enabled={open}
/>
<DueDateChip
value={dueDate}
onChange={chooseDueDate}
open={datePickerOpen}
onOpenChange={setDatePickerOpen}
/>
<GroupChip value={groupId} group={group} groups={groups} onChange={setGroupId} />
</div>
<button
type="button"
disabled={!canSubmit}
onClick={submit}
className="bg-primary text-primary-foreground flex h-7 shrink-0 cursor-pointer items-center rounded-full px-2.5 text-xs font-medium shadow-[0_3px_6px_-2px_rgb(0_0_0/0.02),0_1px_1px_rgb(0_0_0/0.04)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
>
{create.isPending ? 'Creating…' : 'Create task'}
</button>
</div>
</DialogContent>
</Dialog>
);
}
/** Deal — async conversation search, or Personal (no deal). Open state is parent-controlled. */
function DealChip({
value,
onChange,
open,
onOpenChange,
enabled,
}: {
value: ChosenConversation | null;
onChange: (v: ChosenConversation | null) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The dialog is open — don't run the search behind a closed modal. */
enabled: boolean;
}) {
const trpc = useTRPC();
const [query, setQuery] = useState('');
const search = useQuery({
// `dealsOnly` because the input says "Search a deal…". Without it this searches every
// conversation the user owns — including the one Cedar minted for each unrecognised
// correspondent — so typing "gmail" answers with a column of recruiters' addresses.
...trpc.crm.searchConversationsMinimal.queryOptions({ query, limit: 8, dealsOnly: true }),
enabled: enabled && open,
});
const conversations =
(
search.data as
| {
conversations?: {
id: string;
name: string | null;
companyName: string | null;
nextStepDate: string | null;
}[];
}
| undefined
)?.conversations ?? [];
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button type="button" className={cn(CHIP, value && CHIP_SET)}>
{value ? (
<ConversationCompanyAvatar
conversationId={value.id}
fallback={value.label}
className="size-3.5 shrink-0 rounded-[3px]"
/>
) : (
<User className="size-3.5 shrink-0" />
)}
<span className="max-w-40 truncate">{value?.label ?? 'Deal'}</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className={PICKER_SURFACE_CLASS}
// Closing this hands focus back to the chip by default, which lands after the date
// picker has opened and dismisses it — the chain would stop dead at the deal.
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={false} className="bg-transparent">
<Cmdk.Input
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Search a deal…"
className="placeholder:text-muted-foreground/70 h-9 w-full bg-transparent px-2.5 text-sm outline-none"
/>
<div className="bg-border/60 h-px" />
<CommandList className="max-h-56 p-1">
<CommandItem
value="__personal__"
onSelect={() => {
onChange(null);
onOpenChange(false);
}}
className={cn(PICKER_ITEM, 'text-muted-foreground')}
>
<User className="size-4 shrink-0" />
Personal
</CommandItem>
{query.trim() && conversations.length === 0 && !search.isLoading && (
<CommandEmpty className="py-3">No deals found.</CommandEmpty>
)}
{conversations.map((c) => {
const label = c.companyName || c.name || 'Untitled';
return (
<CommandItem
key=[redacted]
value={c.id}
onSelect={() => {
onChange({ id: c.id, label, nextStepDate: c.nextStepDate ?? null });
onOpenChange(false);
}}
className={PICKER_ITEM}
>
<ConversationCompanyAvatar
conversationId={c.id}
fallback={label}
className="size-4 shrink-0 rounded-[3px]"
/>
<span className="min-w-0 truncate">{label}</span>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** Due date — natural language, same suggestion engine the old card used. */
function DueDateChip({
value,
onChange,
open,
onOpenChange,
}: {
value: { date: Date; label: string } | null;
onChange: (v: { date: Date; label: string } | null) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [query, setQuery] = useState('');
const fuse = useMemo(() => createDateFuseInstance(), []);
const suggestions = useMemo(
() => generateDateSuggestions(query, { fuseInstance: fuse }),
[query, fuse],
);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button type="button" className={cn(CHIP, value && CHIP_SET)}>
<CalendarClock className="size-3.5 shrink-0" />
<span className="max-w-40 truncate">{value?.label ?? 'Due date'}</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className={PICKER_SURFACE_CLASS}
// Radix restores focus to the trigger chip on close, which would land after the
// chain's own focus call and undo it. The chain decides where focus goes.
onCloseAutoFocus={(e) => e.preventDefault()}
>
<Command shouldFilter={false} className="bg-transparent">
<Cmdk.Input
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Due date — try “tomorrow”…"
className="placeholder:text-muted-foreground/70 h-9 w-full bg-transparent px-2.5 text-sm outline-none"
/>
<div className="bg-border/60 h-px" />
<CommandList className="max-h-56 p-1">
<CommandItem
value="__no_date__"
onSelect={() => {
onChange(null);
onOpenChange(false);
}}
className={cn(PICKER_ITEM, 'text-muted-foreground')}
>
<CircleSlash className="size-4 shrink-0" />
No due date
</CommandItem>
{suggestions.map((s) => (
<CommandItem
key=[redacted]
value={s.id}
onSelect={() => {
onChange({ date: s.date, label: s.label });
onOpenChange(false);
}}
className={PICKER_ITEM}
>
<CalendarClock className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{s.label}</span>
{s.sublabel && (
<span className="text-muted-foreground shrink-0 text-xs">{s.sublabel}</span>
)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** Group — which column the task lands in. Carries the group's own icon, as the board does. */
function GroupChip({
value,
group,
groups,
onChange,
}: {
value: string | null;
group: { name: string; color: string | null; icon: string | null } | null;
groups: { id: string | null; name: string; color: string | null; icon: string | null }[];
onChange: (v: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const MISC = '__misc__';
const ChipIcon = taskGroupIcon(group?.icon);
const options = groups.map((g) => {
const Icon = taskGroupIcon(g.icon);
return {
value: g.id ?? MISC,
label: g.name,
icon: (
<Icon
className="size-4 shrink-0"
style={{ color: g.color ?? undefined }}
/>
),
};
});
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={cn(CHIP, value && CHIP_SET)}>
<ChipIcon
className="size-3.5 shrink-0"
style={group?.color ? { color: group.color } : undefined}
/>
<span className="max-w-40 truncate">{group?.name ?? 'Group'}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" className={PICKER_SURFACE_CLASS}>
<OptionPicker
options={options}
selected={value ? [value] : [MISC]}
placeholder="Move to group…"
onPick={(v) => {
onChange(v === MISC ? null : v);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}