TaskTicketView.tsx23.4 KBView on GitHub /**
* TaskTicketView — a task as its own full-screen ticket, the way a Linear issue reads.
*
* Replaces TaskOutputPanel, which had the right mount points and the wrong shape: it owned the
* entire centre column and spent it on one `max-w-sm` card floated dead centre, so roughly 85%
* of the surface was empty while `notes`, `taskType`, `taskCreatedBy`, `taskOutput`,
* `agentExecutionEnabled`, `createdByExecution` and the execution history were all fetched and
* rendered nowhere. A task that Cedar cannot run — a reminder, a nudge — had nothing to look at.
*
* A header row, then TWO columns centred together as a pair. Nothing inside the ticket is
* divided by a rule — no border under the header, none between the columns, none around the
* blocks in the body.
*
* Tasks › Post-meeting followups 3/17 ↑ ↓
*
* Send HubSpot App Marketplace install… PROPERTIES
* description / notes Status Todo
* <source thread> Due 24 Aug 2026
* (Create draft) done delete Lane ▣ Post-meeting
* Output Slack
* Created by Cedar agent
* Conversation ⌐ Ashkon, Amanda…
*
* The breadcrumb is Tasks › lane › this task — the surface you came from, then the lane the
* task is filed in. It deliberately does NOT lead with the deal: the deal is one property among
* the others, not the thing that names this page.
*
* The header carries only position + prev/next through THIS LANE's open tasks, at the same
* 28px scale `thread-display.tsx` uses. Marking a task done belongs in the properties column,
* not in a second place that has to be kept in sync with the first.
*
* Rendered by `ActiveViewDisplay` (the /tasks · /mail · /conversations overlay) and by
* `DisplayArtifactPanel` (the /agent column) whenever `selectedArtifact.kind === 'task'`. It
* reads its task by id rather than taking it as a prop so both mounts stay trivial.
*/
'use client';
import { ArrowLeft, ChevronDown, ChevronUp, Loader2, Trash2 } from 'lucide-react';
import { Breadcrumb } from '@/components/ui/breadcrumb';
import { TaskOwnerBadge, useForeignTaskOwner } from '@/modules/userTasks/components/TaskOwnerBadge';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { TaskTicketProperties } from '@/modules/userTasks/components/TaskTicketProperties';
import { ConversationBadge } from '@/modules/threads/thread/components/ConversationBadge';
import { ThreadDataSync } from '@/modules/threads/thread/components/thread-data-sync';
import { useTaskIsExecuting } from '@/modules/userTasks/hooks/use-task-is-executing';
import { hasTaskOutput } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
import type { ParsedMessage } from '@/modules/threads/threadList/store/threadSlice';
import { Thread } from '@/modules/threads/threadList/threadItem/components/thread';
import { lastTasksLayout } from '@/modules/userTasks/components/TasksLayoutToggle';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { EditableText } from '@/modules/conversations/components/EditableText';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TabBotIcon } from '@/components/icons/animated/bot-thinking';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { SlackLogo } from '@/modules/inbox/components/channel-icons';
import { useCallback, useEffect, useState } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
export function TaskTicketView({ taskId }: { taskId: string }) {
const trpc = useTRPC();
const clearArtifact = useCedarStore((s) => s.clearArtifact);
const [, setSelectedTaskId] = useQueryState('task');
const queryClient = useQueryClient();
const { sendSlackDraftFromTask, optimisticDeleteTask, optimisticUpdateTaskDescription } =
useOptimisticTaskActions();
const executeTaskNow = useExecuteTaskNow();
const saveNotes = useMutation({
...trpc.userTasks.updateTask.mutationOptions(),
onSuccess: () =>
void queryClient.invalidateQueries({
queryKey=[redacted] taskId }),
}),
onError: () => toast.error('Could not save the notes'),
});
const openThread = useCedarStore((s) => s.openThread);
const openConversationContext = useCedarStore((s) => s.openConversationContext);
const [isSendingSlack, setIsSendingSlack] = useState(false);
const [isGutterHovered, setIsGutterHovered] = useState(false);
// Closing clears the task artifact (→ back to the list) and drops the highlight, matching how
// the conversation/thread overlays dismiss.
const close = useCallback(() => {
clearArtifact();
void setSelectedTaskId(null);
}, [clearArtifact, setSelectedTaskId]);
// Escape does the same, as it does in those overlays.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
close();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [close]);
const { data, isLoading, isError } = useQuery(
trpc.userTasks.getTaskById.queryOptions({ taskId }),
);
const task = data?.task ?? null;
// Whose task this is when it isn't yours, else null. A hook, so it sits above the early
// returns below rather than beside the render that consumes it.
const foreignOwner = useForeignTaskOwner(task?.user);
// Shared definition of "this task is running", so the button reads the same here as on the
// board — driven first by the slice's instant flag, then by the run thread's own state.
const isExecuting = useTaskIsExecuting(
taskId,
(task as { chatThreadId?: string | null } | null)?.chatThreadId ?? null,
);
// Prev/next over the OPEN tasks in this task's own lane. Unscoped it read "22/839" — a
// number about the whole account rather than about the queue you are working, and one that
// makes the arrows meaningless.
const laneId = (task as { taskGroupId?: string | null } | null)?.taskGroupId ?? null;
const { data: listData } = useQuery({
...trpc.userTasks.listUserTasks.queryOptions({
status: 'todo',
...(laneId ? { taskGroupId: laneId } : {}),
}),
enabled: !!task,
});
const siblings = ((listData as { tasks?: { id: string }[] } | undefined)?.tasks ?? []).filter(
(t) => t.id,
);
const position = siblings.findIndex((t) => t.id === taskId);
const goTo = (index: number) => {
const next = siblings[index];
if (!next) return;
void setSelectedTaskId(next.id);
useCedarStore.getState().setSelectedArtifact({ kind: 'task', id: next.id });
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-surface">
<p className="text-sm text-muted-foreground">Loading task…</p>
</div>
);
}
// A task can go missing (hard-deleted, or on a conversation this session can't see). The ticket
// is the whole centre column, so without an explicit way out this state is a dead end for
// anyone who doesn't know Escape closes it.
if (isError || !task) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-surface">
<p className="text-sm text-muted-foreground">Could not load this task.</p>
<Button variant="outline" size="sm" onClick={close} className="cursor-pointer">
Go back
</Button>
</div>
);
}
const conversationId = task.conversationId ?? null;
const title = task.description || 'Untitled task';
// Tasks on a shared org conversation are readable by the whole team; a teammate's task is
// labelled as theirs below. Sending a draft it already produced is not owner-only, so the Slack
// draft stays actionable either way.
const isMine = !foreignOwner;
// The Slack draft the agent produced, if this task carries one. Shown in full: the whole point
// of opening a task is to read what would go out before it goes out.
const slackDraft = task.taskActionData?.channel === 'slack' ? task.taskActionData : null;
const canSendSlack = !!slackDraft?.message && !!slackDraft.channelId && !!slackDraft.workspaceId;
// Execute is the task's action only while it has produced nothing — a task with an output is
// opened, not re-run — and only on your own task.
const canExecute =
isMine && !hasTaskOutput(task.taskActionData as ConversationTaskActionData | null);
const handleSendSlack = async () => {
if (isSendingSlack || !slackDraft) return;
setIsSendingSlack(true);
try {
await sendSlackDraftFromTask(taskId, slackDraft);
await Promise.all([
queryClient.invalidateQueries({
queryKey=[redacted] taskId }),
}),
queryClient.invalidateQueries({ queryKey=[redacted] }),
]);
} catch {
// sendSlackDraftFromTask already toasted the failure.
} finally {
setIsSendingSlack(false);
}
};
// The event whose processing produced this task, flattened out of the creation run. Only an
// email event can be opened precisely (it carries a thread); anything else lands on the
// conversation timeline the event lives on, which is the closest thing to a deep link that
// exists today.
const execEvent = (
task as {
createdByExecution?: {
event?: {
id: string;
title: string | null;
eventType: string | null;
emailEvent?: { threadId?: string | null } | null;
} | null;
} | null;
} | null
)?.createdByExecution?.event;
const triggeringEvent = execEvent
? {
id: execEvent.id,
title: execEvent.title,
eventType: execEvent.eventType,
threadId: execEvent.emailEvent?.threadId ?? null,
}
: null;
const openTriggeringEvent = triggeringEvent?.threadId
? () => openThread(triggeringEvent.threadId!)
: conversationId
? () => openConversationContext(conversationId)
: undefined;
const laneName = task.taskGroup?.name ?? 'Misc';
const laneHref = `/tasks/agenda?group=${task.taskGroup?.id ?? 'misc'}`;
return (
<div className="@container flex h-full w-full flex-col overflow-hidden bg-surface">
{/* Back — the same affordance thread-display and the conversation view carry: the whole
left margin is the target, lighting up under the pointer, and it collapses to a small
button in the header on a narrow window where there is no margin to spare. Closing
returns you to whatever you opened the task from rather than a fixed destination. */}
<div className="pointer-events-none sticky top-0 z-10 h-0 w-full @max-3xl:hidden">
<button
type="button"
onClick={close}
onMouseEnter={() => setIsGutterHovered(true)}
onMouseLeave={() => setIsGutterHovered(false)}
aria-label="Back"
className="pointer-events-auto absolute left-0 top-0 h-screen w-[max(2rem,calc((100%-1020px)/2))] cursor-pointer"
>
<div
className={cn(
'absolute inset-0 bg-gradient-to-r from-[#15803d]/[0.10] to-transparent transition-opacity duration-300 dark:from-[#15803d]/[0.14]',
isGutterHovered ? 'opacity-100' : 'opacity-0',
)}
/>
<ArrowLeft
className={cn(
'absolute left-4 top-3 h-4 w-4 transition-opacity duration-200',
isGutterHovered ? 'opacity-80' : 'opacity-40',
)}
/>
</button>
</div>
{/* Header — constrained to the same width as the columns below, so the breadcrumb starts
where the title does and the actions end where the properties do. No bottom border:
nothing inside a single task is divided by a rule. */}
<div className="shrink-0">
<div className="mx-auto flex h-11 w-full max-w-[1020px] items-center gap-1.5 px-8 text-sm">
{/* Back — the same affordance thread-display and the conversation view carry. It
closes the ticket rather than navigating, so it returns you to whatever you opened
the task FROM, not to a fixed destination. */}
<button
type="button"
onClick={close}
aria-label="Back"
className="text-muted-foreground hover:bg-muted hover:text-foreground mr-0.5 hidden h-6 w-6 shrink-0 items-center justify-center rounded transition-colors @max-3xl:flex"
>
<ArrowLeft className="h-4 w-4" />
</button>
{/* Lane only — the task's own title is the h1 directly below, so repeating it here is
noise that also squeezes the actions on a narrow window. */}
<Breadcrumb
className="py-0"
items={[
{ label: 'Tasks', href: `/tasks/${lastTasksLayout()}` },
{ label: laneName, href: laneHref },
]}
/>
<div className="ml-auto flex shrink-0 items-center gap-1">
<TaskOwnerBadge owner={foreignOwner} />
{/* Position + prev/next through this lane's open tasks. Status and the rest of the
actions live in the properties column — a second place to mark a task done is a
second place to keep in sync. */}
{position >= 0 && siblings.length > 1 && (
<>
<span className="text-muted-foreground mr-1 text-xs tabular-nums">
{position + 1}/{siblings.length}
</span>
<button
type="button"
aria-label="Previous task"
disabled={position <= 0}
onClick={() => goTo(position - 1)}
className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Next task"
disabled={position >= siblings.length - 1}
onClick={() => goTo(position + 1)}
className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChevronDown className="h-4 w-4" />
</button>
</>
)}
</div>
</div>
</div>
{/* Two columns, centred together as a pair with no rule between them: the body and the
properties are the same page, not a document with a panel attached to it. */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1020px] items-start gap-12 px-8 py-10">
<div className="min-w-0 flex-1">
{/* Title and notes are both edited in place with the shared EditableText — no
borders, no Save button, committed on blur. Multiline because both wrap, but the
title still finishes on Enter: it is one line even when it takes two. */}
{isMine ? (
<EditableText
value={title}
ariaLabel="Task description"
multiline
commitOnEnter
underline={false}
alwaysEditable
className="text-2xl font-semibold leading-snug text-foreground"
onSave={(next) =>
void optimisticUpdateTaskDescription(taskId, next, conversationId ?? undefined)
}
/>
) : (
<h1 className="text-2xl font-semibold leading-snug text-foreground">{title}</h1>
)}
{/* The deal — the same ConversationBadge the open-thread header carries, so the
conversation reads and behaves identically wherever you meet it. It takes the
conversation directly and opens it itself. */}
{conversationId && (
<div className="mt-2">
<ConversationBadge conversationId={conversationId} linkable={false} />
</div>
)}
{/* The task's own context — why it exists, what to say. Editable in place for the
same reason the title is: it is prose the agent wrote, and correcting it should
not mean opening something else. Empty prompts rather than rendering nothing, so
there is somewhere to click. */}
{isMine ? (
<div className="mt-6">
<EditableText
value={task.notes ?? ''}
ariaLabel="Task notes"
multiline
underline={false}
alwaysEditable
placeholder="Add notes…"
className={cn(
'text-sm leading-relaxed',
task.notes ? 'text-foreground' : 'text-muted-foreground',
)}
onSave={(next) => saveNotes.mutate({ taskId, notes: next })}
/>
</div>
) : (
task.notes && (
<p className="mt-6 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{task.notes}
</p>
)
)}
{/* The thread this task is ABOUT (source_thread_id), when it named one. Rendered
with the same ThreadDataSync + Thread pair the conversation timeline uses for an
email row, so it hydrates itself and clicking it opens the full thread — the
ticket is a way INTO the mail, not a dead end. Absent thread renders nothing at
all: an empty box would imply mail that does not exist. */}
{task.sourceThreadId && (
<div className="mt-6 w-full">
<ThreadDataSync threadId={task.sourceThreadId} />
{/* Full width of the body column and tinted rather than outlined, so it lines up
with the title and the description instead of sitting in its own box. */}
<div className="bg-muted/40 w-full overflow-hidden rounded-lg">
<Thread
message={{ id: task.sourceThreadId } as ParsedMessage}
onClick={(message: ParsedMessage) => () =>
openThread(message.threadId ?? message.id)
}
noPadding
/>
</div>
</div>
)}
{slackDraft && (
<div className="bg-muted/40 mt-6 w-full space-y-2 rounded-lg px-3 py-2.5">
<div className="flex items-center gap-1.5">
<SlackLogo className="size-3.5 shrink-0" />
<span className="text-sm font-semibold">
{slackDraft.channelName
? `#${slackDraft.channelName.replace(/^#/, '')}`
: 'Slack'}
</span>
</div>
{slackDraft.message && (
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-sm text-foreground">
{slackDraft.message}
</p>
)}
<button
type="button"
onClick={handleSendSlack}
disabled={isSendingSlack || !canSendSlack}
className="bg-action text-action-foreground inline-flex h-7 cursor-pointer items-center justify-center rounded-full px-4 text-xs font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSendingSlack ? 'Sending…' : 'Send and mark done'}
</button>
</div>
)}
{/* The task's actions, on one row: running it on the left, finishing or discarding
it on the right. Create draft is the task card's button exactly — same pill, same
action colour, same bot glyph, same Drafting state — so one action does not look
like two different things depending on where you found the task. */}
{isMine && (
<div className="mt-8 flex items-center gap-2">
{canExecute && (
<button
type="button"
onClick={() =>
void executeTaskNow({ taskId, conversationId, description: title })
}
disabled={isExecuting}
className="bg-action text-action-foreground inline-flex h-7 cursor-pointer items-center justify-center gap-1 rounded-full px-4 text-xs font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isExecuting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<TabBotIcon isProcessing={false} isFinished={false} className="h-3.5 w-3.5" />
)}
{isExecuting ? 'Drafting…' : 'Create draft'}
</button>
)}
<button
type="button"
aria-label="Delete task"
title="Delete task"
onClick={() => {
void optimisticDeleteTask(taskId);
close();
}}
className="text-muted-foreground group ml-auto inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors hover:bg-[#FDE4E9] hover:text-[#F43F5E] dark:hover:bg-[#411D23]"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
{!isMine && (
<p className="mt-6 text-sm text-muted-foreground">
{slackDraft
? `${foreignOwner?.name || 'A teammate'}'s task — sending it from here goes out as you and marks it done.`
: `${foreignOwner?.name || 'A teammate'}'s task — only they can run it.`}
</p>
)}
</div>
<TaskTicketProperties
task={{
id: taskId,
status: task.status,
dueDate: task.dueDate ?? null,
taskGroup: task.taskGroup ?? null,
taskOutput: task.taskOutput ?? null,
taskCreatedBy: task.taskCreatedBy ?? null,
createdAt: task.createdAt ?? null,
conversationId,
executions: task.executions ?? [],
triggeringEvent,
}}
// A teammate's task is read-only here: all four mutations are owner-scoped server side,
// so offering the controls would only produce a failed write.
editable={isMine}
onOpenTriggeringEvent={triggeringEvent ? openTriggeringEvent : undefined}
/>
</div>
</div>
</div>
);
}