TaskListView.tsx19.4 KBView on GitHub 'use client';
/**
* TaskListView — every open task as rows, grouped into Linear-style sections.
*
* Grouped by default (task group). Each section is a full-width header band — collapse chevron,
* colour dot, name, count, and a hover "+" — followed by flat, dense, hairline-separated rows and a
* bottom inline composer. The toolbar's Group-by (task group / due date), Ordering, Filters, Show
* completed and Display properties all drive this via URL params (see useTaskListViewOptions).
*
* Like the board it reads `listUserTasks` (status 'todo', limit 500) as a hydration feed into the
* slice and renders SOLELY from the slice (`useTodoTasks`), so optimistic complete/snooze/delete
* take effect instantly. Completed tasks (opt-in) come from their own status:'done' query.
*/
import {
makeTaskFilter,
MISC_FILTER_KEY,
NO_STATUS_KEY,
} from '@/modules/userTasks/utils/task-filters';
import { useOpenTaskInExecutionMode } from '@/modules/userTasks/hooks/use-open-task-in-execution-mode';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { useTaskListViewOptions } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { openConversationFromAgenda } from '@/modules/agentCanvas/utils/open-conversation';
import { useHydrateTasksSlice } from '@/modules/userTasks/hooks/use-hydrate-tasks-slice';
import { useTaskListHotkeys } from '@/modules/userTasks/hooks/use-task-list-hotkeys';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { humanizeStatus } from '@/modules/userTasks/utils/humanize-status';
import { listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { useDayKey } from '@/modules/userTasks/hooks/use-day-boundary';
import { enterChatThread } from '@/modules/ux/layout/enterChatThread';
import { DatePickerDialog } from '@/components/ui/date-picker-dialog';
import { compareTasks } from '@/modules/userTasks/utils/task-order';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { useCedarStore, useTodoTasks } from '@/modules/store';
import { DEFAULT_STATUS_OPTIONS } from '@/modules/crm/types';
import { useTRPC } from '@/providers/query-provider';
import type { TaskCardTask } from './TaskKanbanCard';
import { useMemo, useRef, useState } from 'react';
import { NewTaskDialog } from './NewTaskDialog';
import { TaskListRow } from './TaskListRow';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
// The grouping keys are the filter model's keys — a section and its filter option must agree.
const MISC_KEY=[redacted];
/** Pipeline-order rank for a deal status (from the default CRM stages); unknown/custom sort after. */
const STATUS_ORDER = new Map(DEFAULT_STATUS_OPTIONS.map((o, i) => [o.value, i]));
/**
* A labelled bucket for the due-date grouping — mail-style, most-recent first: Today, then the
* recent past sliding to the distant past. There's no "Upcoming" lane — anything due today or later
* folds into Today, so the sections are exclusively Today · Yesterday · Last 7 days · Older.
*/
function dueBucket(dueDate?: string | Date | null): { key=[redacted]; label: string; order: number } {
if (!dueDate) return { key=[redacted], label: 'No date', order: 6 };
const d = new Date(dueDate).getTime();
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const day = 24 * 60 * 60 * 1000;
if (d >= startToday) return { key=[redacted], label: 'Today', order: 1 };
if (d >= startToday - day) return { key=[redacted], label: 'Yesterday', order: 2 };
if (d >= startToday - 7 * day) return { key=[redacted], label: 'Last 7 days', order: 3 };
return { key=[redacted], label: 'Older', order: 4 };
}
/** One labelled section of rows. `groupId` (grouping by task group) files new rows into it. */
interface TaskBlock {
key=[redacted];
label: string;
groupId: string | null;
color: string | null;
tasks: TaskCardTask[];
}
/** Linear-style section header band. */
function GroupHeader({
label,
count,
color,
collapsed,
onToggle,
onAdd,
}: {
label: string;
count: number;
color: string | null;
collapsed: boolean;
onToggle: () => void;
onAdd: () => void;
}) {
return (
<div className="group/gh bg-muted/40 flex items-center gap-1.5 rounded-md px-2 py-1">
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? `Expand ${label}` : `Collapse ${label}`}
className="text-muted-foreground hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center"
>
{collapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
</button>
<span
className={cn('size-2 shrink-0 rounded-full', !color && 'bg-muted-foreground/40')}
style={color ? { backgroundColor: color } : undefined}
/>
<span className="text-foreground truncate text-sm font-semibold">{label}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">{count}</span>
<button
type="button"
onClick={onAdd}
aria-label={`Add a task to ${label}`}
className="text-muted-foreground hover:bg-sunken hover:text-foreground ml-auto flex size-5 shrink-0 cursor-pointer items-center justify-center rounded opacity-0 transition-opacity group-hover/gh:opacity-100"
>
<Plus className="size-3.5" />
</button>
</div>
);
}
export function TaskListView() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const openTaskInExecutionMode = useOpenTaskInExecutionMode();
const executeTaskNow = useExecuteTaskNow();
const [selectedTaskId] = useQueryState('task', { history: 'push' });
const { optimisticCompleteTask, optimisticSnoozeTask, optimisticDeleteTask } =
useOptimisticTaskActions();
const {
groupBy,
showCompleted,
visibleProps,
filterGroups,
filterChannels,
filterStatuses,
filterCrm,
filterTypes,
hideFuture,
} = useTaskListViewOptions();
const [snoozeTaskId, setSnoozeTaskId] = useState<string | null>(null);
// Session-only collapse state, keyed by section key.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// The section whose inline "add task" composer is currently open (its key), or null.
const [addingKey, setAddingKey] = useState<string | null>(null);
// The row under the cursor — the target for the e/s/w/x hotkeys, falling back to the open task.
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
// The section under the cursor — the target for the `c` (new task) hotkey.
const [hoveredSectionKey, setHoveredSectionKey] = useState<string | null>(null);
// Bulk selection (⌘/shift-click + x/shift+x), shared with the board.
const toggleTaskSelection = useCedarStore((s) => s.toggleTaskSelection);
const setTaskSelection = useCedarStore((s) => s.setTaskSelection);
const setTaskSelectionAnchorId = useCedarStore((s) => s.setTaskSelectionAnchorId);
const clearTaskSelection = useCedarStore((s) => s.clearTaskSelection);
const { data: tasksData } = useQuery(
trpc.userTasks.listUserTasks.queryOptions({
status: 'todo',
withConversation: true,
limit: 500,
sortDueDate: 'asc',
}),
);
useHydrateTasksSlice(tasksData?.tasks);
const { data: completedData } = useQuery({
...trpc.userTasks.listUserTasks.queryOptions({
status: 'done',
withConversation: true,
limit: 100,
sortDueDate: 'desc',
}),
enabled: showCompleted,
});
const { data: groupsData } = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));
const reopenTask = useMutation(trpc.userTasks.updateTaskStatus.mutationOptions());
const tasks = useTodoTasks() as unknown as TaskCardTask[];
// The Board's comparator, not a second copy of it. Both surfaces render the stored `sortOrder`,
// so re-seeding it from the toolbar (or dragging a card on the board) moves the row here too.
const sortRows = useMemo(() => {
const compare = compareTasks();
return (rows: TaskCardTask[]) => [...rows].sort(compare);
}, []);
const dayKey=[redacted];
// Future-due tasks are hidden by default here (a task due after today is not yet actionable);
// the Board keeps them, in its Upcoming column.
// `dayKey` re-runs this at local midnight: `hideFuture` compares against end-of-today, and a tab
// left open overnight would otherwise keep yesterday's boundary and hide work that came due.
const passesFilters = useMemo(
() =>
makeTaskFilter({
filterGroups,
filterChannels,
filterStatuses,
filterCrm,
filterTypes,
hideFuture,
}),
[filterGroups, filterChannels, filterStatuses, filterCrm, filterTypes, hideFuture, dayKey],
);
const ordered = useMemo(
() => sortRows(tasks.filter(passesFilters)),
[tasks, passesFilters, sortRows],
);
// Split into sections. Colours come from the group registry.
const blocks = useMemo<TaskBlock[]>(() => {
if (groupBy === 'none')
return [{ key: 'all', label: 'Tasks', groupId: null, color: null, tasks: ordered }];
if (groupBy === 'due') {
const byKey = new Map<string, { label: string; order: number; tasks: TaskCardTask[] }>();
for (const t of ordered) {
const b = dueBucket(t.dueDate);
const entry = byKey.get(b.key) ?? { label: b.label, order: b.order, tasks: [] };
entry.tasks.push(t);
byKey.set(b.key, entry);
}
return [...byKey.entries()]
.sort((a, b) => a[1].order - b[1].order)
.map(([key, v]) => ({ key, label: v.label, groupId: null, color: null, tasks: v.tasks }));
}
if (groupBy === 'status') {
// Group by the attached deal's pipeline stage; ungrouped/dealless tasks go to "No stage".
const byKey = new Map<string, { label: string; order: number; tasks: TaskCardTask[] }>();
for (const t of ordered) {
const status = t.conversation?.status ?? null;
const key=[redacted] ?? NO_STATUS_KEY;
const order = status ? (STATUS_ORDER.get(status) ?? 900) : 1000;
const label = status ? humanizeStatus(status) : 'No stage';
const entry = byKey.get(key) ?? { label, order, tasks: [] };
entry.tasks.push(t);
byKey.set(key, entry);
}
return [...byKey.entries()]
.sort((a, b) => a[1].order - b[1].order || a[1].label.localeCompare(b[1].label))
.map(([key, v]) => ({ key, label: v.label, groupId: null, color: null, tasks: v.tasks }));
}
// groupBy === 'group': task-group order, Misc last.
const groups = groupsData?.groups ?? [];
const byKey = new Map<string, TaskCardTask[]>();
for (const t of ordered) {
const key = (t as { taskGroupId?: string | null }).taskGroupId ?? MISC_KEY;
(byKey.get(key) ?? byKey.set(key, []).get(key)!).push(t);
}
const out: TaskBlock[] = [];
for (const g of groups) {
if (g.isMisc || !g.id) continue; // Misc synthesized below
const list = byKey.get(g.id);
if (list?.length)
out.push({ key=[redacted], label: g.name, groupId: g.id, color: g.color ?? null, tasks: list });
}
const misc = byKey.get(MISC_KEY);
if (misc?.length)
out.push({ key=[redacted], label: 'Misc', groupId: null, color: null, tasks: misc });
return out;
}, [groupBy, ordered, groupsData]);
const completed = useMemo(
() => ((completedData?.tasks ?? []) as unknown as TaskCardTask[]).filter(passesFilters),
[completedData, passesFilters],
);
const refetchTasks = () =>
void queryClient.invalidateQueries({ queryKey=[redacted] });
// Execute runs the task on its own chat thread — which the sidepanel switches to, so the run is
// watchable — and attaches the produced draft back to the task: the row shows an Executing state,
// then flips to "Open draft". The list itself stays put; the task is not opened.
const handleExecute = (task: TaskCardTask) =>
void executeTaskNow({
taskId: task.id,
conversationId: task.conversationId,
description: task.description || 'Untitled task',
});
// The row's "Chat" button repoints the sidepanel at the task's run without leaving the list, so
// it enters the chat in place (no navigate) — see enterChatThread.
const openChat = (chatThreadId: string) => enterChatThread(chatThreadId);
const toggleCollapse = (key=[redacted] =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Header "+" — open (and expand) this section's inline composer.
const startAdding = (key=[redacted] => {
setCollapsed((prev) => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
setAddingKey(key);
};
// Flat visible order (sections top-to-bottom, rows in each) for shift-range selection.
const orderedIds = useMemo(() => blocks.flatMap((b) => b.tasks.map((t) => t.id)), [blocks]);
const orderedIdsRef = useRef<string[]>([]);
orderedIdsRef.current = orderedIds;
// A key acts on the whole selection when there is one, else the single target (hovered/open).
const actOnTargets = (id: string, fn: (taskId: string) => void) => {
const sel = useCedarStore.getState().taskSelection;
const ids = sel.length ? sel : [id];
ids.forEach(fn);
if (sel.length) clearTaskSelection();
};
const toggleSelect = (id: string) => {
toggleTaskSelection(id);
setTaskSelectionAnchorId(id);
};
const rangeSelect = (id: string) => {
const ids = orderedIdsRef.current;
const { taskSelectionAnchorId, taskSelection } = useCedarStore.getState();
const anchor = taskSelectionAnchorId ?? id;
const ai = ids.indexOf(anchor);
const ti = ids.indexOf(id);
if (ai === -1 || ti === -1) {
toggleSelect(id);
return;
}
const [start, end] = ai <= ti ? [ai, ti] : [ti, ai];
setTaskSelection([...taskSelection, ...ids.slice(start, end + 1)]);
};
const openRow = (t: TaskCardTask) => {
clearTaskSelection();
setTaskSelectionAnchorId(t.id);
openTaskInExecutionMode(t);
};
useTaskListHotkeys({
getTargetId: () => hoveredTaskId ?? selectedTaskId,
onCreate: () => {
// Open the composer in the hovered section, falling back to the first one.
const key=[redacted] ?? blocks[0]?.key;
if (key) startAdding(key);
},
onComplete: (id) => actOnTargets(id, (t) => void optimisticCompleteTask(t, 'checked-off')),
onSnooze: (id) => setSnoozeTaskId(id),
onDelete: (id) => actOnTargets(id, (t) => void optimisticDeleteTask(t)),
onToggleSelect: toggleSelect,
onRangeSelect: rangeSelect,
});
const renderRow = (task: TaskCardTask) => (
<TaskListRow
key=[redacted]
task={task}
isActive={task.id === selectedTaskId}
visibleProps={visibleProps}
onOpen={openRow}
onOpenConversation={(id) => openConversationFromAgenda(id)}
onOpenChat={openChat}
onExecute={handleExecute}
onToggleComplete={(id) => void optimisticCompleteTask(id, 'checked-off')}
onSnooze={(id) => setSnoozeTaskId(id)}
onDelete={(id) => void optimisticDeleteTask(id)}
onToggleSelect={toggleSelect}
onRangeSelect={rangeSelect}
onHover={setHoveredTaskId}
/>
);
const empty = blocks.every((b) => b.tasks.length === 0);
const completedCollapsed = collapsed.has('__completed__');
return (
<>
<div className="flex flex-col gap-1 pl-6">
{empty ? (
<div className="text-muted-foreground px-2 py-10 text-center text-sm">
No active tasks
<div className="mt-3">
<button
type="button"
onClick={() => startAdding(blocks[0]?.key ?? 'all')}
className="text-muted-foreground hover:bg-muted/50 hover:text-foreground mx-auto flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors"
>
<Plus className="size-3.5 shrink-0" />
Add task
</button>
</div>
</div>
) : (
blocks.map((block) => {
const isCollapsed = collapsed.has(block.key);
return (
<div
key=[redacted]
className="flex flex-col"
onMouseEnter={() => setHoveredSectionKey(block.key)}
onMouseLeave={() =>
setHoveredSectionKey((prev) => (prev === block.key ? null : prev))
}
>
<GroupHeader
label={block.label}
count={block.tasks.length}
color={block.color}
collapsed={isCollapsed}
onToggle={() => toggleCollapse(block.key)}
onAdd={() => startAdding(block.key)}
/>
{!isCollapsed && (
<div className="divide-border/40 divide-y">{block.tasks.map(renderRow)}</div>
)}
</div>
);
})
)}
{/* Completed — opt-in section. Un-checking reopens the task. */}
{showCompleted && completed.length > 0 && (
<div className="mt-1 flex flex-col">
<GroupHeader
label="Completed"
count={completed.length}
color={null}
collapsed={completedCollapsed}
onToggle={() => toggleCollapse('__completed__')}
onAdd={() => {}}
/>
{!completedCollapsed && (
<div className="divide-border/40 divide-y">
{completed.map((task) => (
<TaskListRow
key=[redacted]
task={task}
visibleProps={visibleProps.filter((p) => p !== 'action')}
onOpen={(t) => openTaskInExecutionMode(t)}
onOpenConversation={(id) => openConversationFromAgenda(id)}
onToggleComplete={(id) =>
reopenTask.mutate(
{ taskId: id, status: 'todo' },
{
onSuccess: refetchTasks,
onError: () => toast.error('Could not reopen task'),
},
)
}
/>
))}
</div>
)}
</div>
)}
</div>
{/* The natural-language snooze picker — same modal the board and email snooze use. */}
<DatePickerDialog
open={snoozeTaskId !== null}
onOpenChange={(open) => !open && setSnoozeTaskId(null)}
onSelect={(date) => {
if (snoozeTaskId) void optimisticSnoozeTask(snoozeTaskId, date);
setSnoozeTaskId(null);
}}
title="Snooze until"
placeholder="Try: tomorrow, next week, or aug 7"
/>
{/* Creating a task is a modal, not a row spliced into the section — see NewTaskDialog.
The section that opened it only decides which group the task lands in. */}
<NewTaskDialog
open={addingKey !== null}
onOpenChange={(open) => {
if (!open) setAddingKey(null);
}}
taskGroupId={blocks.find((b) => b.key === addingKey)?.groupId ?? null}
onCreated={refetchTasks}
/>
</>
);
}