1import { CheckIcon, CircleDotIcon, CircleIcon, ListTodoIcon } from "lucide-react";
2import { memo, type ComponentProps } from "react";
3
4import { formatDuration } from "../../session-logic";
5import { cn } from "~/lib/utils";
6import { ComposerBanner } from "./ComposerBanner";
7
8export interface ComposerTasksProgress {
9 readonly step: string;
10 readonly completedSteps: number;
11 readonly totalSteps: number;
12}
13
14export interface ComposerTaskStep {
15 readonly durationMs?: number;
16 readonly step: string;
17 readonly status: "pending" | "inProgress" | "completed";
18}
19
20const MAX_TASK_SEGMENTS = 10;
21
22const taskStatusLabels = {
23 pending: "Pending",
24 inProgress: "Running",
25 completed: "Completed",
26} satisfies Record<ComposerTaskStep["status"], string>;
27
28function keyedTaskSteps(steps: readonly ComposerTaskStep[]) {
29 const occurrences = new Map<string, number>();
30 return steps.map((step) => {
31 const occurrence = occurrences.get(step.step) ?? 0;
32 occurrences.set(step.step, occurrence + 1);
33 return { key: `${step.step}:${occurrence}`, step };
34 });
35}
36
37function TaskSegments({
38 className,
39 steps,
40}: {
41 readonly className?: string;
42 readonly steps: readonly ComposerTaskStep[];
43}) {
44 if (steps.length <= 1 || steps.length > MAX_TASK_SEGMENTS) return null;
45
46 return (
47 <span aria-hidden className={cn("flex w-10 shrink-0 items-center gap-0.5", className)}>
48 {keyedTaskSteps(steps).map(({ key, step }) => (
49 <span
50 key={key}
51 className={cn(
52 "h-[3px] min-w-0 flex-1 rounded-full",
53 step.status === "completed"
54 ? "bg-success"
55 : step.status === "inProgress"
56 ? "bg-primary"
57 : "bg-muted-foreground/25",
58 )}
59 />
60 ))}
61 </span>
62 );
63}
64
65function TaskSummary({
66 expanded,
67 progress,
68 steps,
69}: {
70 readonly expanded: boolean;
71 readonly progress: ComposerTasksProgress;
72 readonly steps: readonly ComposerTaskStep[];
73}) {
74 return (
75 <>
76 <ComposerBanner.Icon>
77 <ListTodoIcon />
78 </ComposerBanner.Icon>
79 <ComposerBanner.Content>
80 <span className="shrink-0 text-muted-foreground">Tasks</span>
81 <span
82 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
83 data-composer-task-current="true"
84 >
85 {progress.step}
86 </span>
87 </ComposerBanner.Content>
88 <ComposerBanner.Actions>
89 <ComposerBanner.Count
90 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
91 data-composer-task-progress="true"
92 >
93 {progress.completedSteps}/{progress.totalSteps}
94 </ComposerBanner.Count>
95 <TaskSegments className="hidden w-20 @min-[560px]:flex" steps={steps} />
96 <ComposerBanner.ToggleIcon expanded={expanded} />
97 </ComposerBanner.Actions>
98 </>
99 );
100}
101
102export const ComposerTasksBadge = memo(function ComposerTasksBadge({
103 expanded,
104 onToggle,
105 placement = "tab",
106 progress,
107 steps,
108}: {
109 readonly expanded: boolean;
110 readonly onToggle: () => void;
111 readonly placement?: "inline" | "tab";
112 readonly progress: ComposerTasksProgress;
113 readonly steps: readonly ComposerTaskStep[];
114}) {
115 if (progress.totalSteps <= 0) return null;
116
117 const row = (
118 <ComposerBanner.Row
119 render={<button type="button" />}
120 aria-expanded={expanded}
121 aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
122 data-composer-tasks-badge="true"
123 onClick={onToggle}
124 onPointerDown={(event) => event.preventDefault()}
125 >
126 <TaskSummary expanded={expanded} progress={progress} steps={steps} />
127 </ComposerBanner.Row>
128 );
129 return placement === "inline" ? (
130 row
131 ) : (
132 <ComposerBanner.Root density="comfortable" data-composer-shoulder-tab>
133 {row}
134 </ComposerBanner.Root>
135 );
136});
137
138export const ComposerTasksContent = memo(function ComposerTasksContent({
139 expanded,
140 onToggle,
141 progress,
142 steps,
143}: {
144 readonly expanded: boolean;
145 readonly onToggle: () => void;
146 readonly progress: ComposerTasksProgress;
147 readonly steps: readonly ComposerTaskStep[];
148}) {
149 return (
150 <div
151 data-chat-composer-collapsed-controls="true"
152 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}
153 >
154 <ComposerTasksBadge
155 expanded={expanded}
156 onToggle={onToggle}
157 placement="inline"
158 progress={progress}
159 steps={steps}
160 />
161 {expanded ? (
162 <ComposerBanner.Scroll data-composer-tasks-scroll="true">
163 <ComposerBanner.Children
164 render={<ul role="list" />}
165 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
166 data-composer-tasks-list="true"
167 >
168 {keyedTaskSteps(steps).map(({ key, step }) => (
169 <ComposerBanner.Row key={key} render={<li />} className="items-start py-1 pe-2">
170 <ComposerBanner.Icon
171 className={cn(
172 "h-4",
173 step.status === "completed"
174 ? "text-success"
175 : step.status === "inProgress"
176 ? "text-primary"
177 : "text-muted-foreground/40",
178 )}
179 >
180 {step.status === "completed" ? (
181 <CheckIcon />
182 ) : step.status === "inProgress" ? (
183 <CircleDotIcon />
184 ) : (
185 <CircleIcon />
186 )}
187 </ComposerBanner.Icon>
188 <ComposerBanner.Content
189 className={cn(
190 "block wrap-anywhere",
191 step.status === "completed"
192 ? "text-muted-foreground/55"
193 : step.status === "inProgress"
194 ? "text-foreground/90"
195 : "text-muted-foreground/70",
196 )}
197 >
198 <span className="sr-only">{taskStatusLabels[step.status]}: </span>
199 {step.step}
200 </ComposerBanner.Content>
201 <ComposerBanner.Actions>
202 <span
203 className="w-12 text-right text-3xs/4 text-muted-foreground/45 tabular-nums"
204 data-composer-task-duration="true"
205 >
206 {step.durationMs !== undefined
207 ? formatDuration(step.durationMs)
208 : step.status === "inProgress"
209 ? "now"
210 : null}
211 </span>
212 </ComposerBanner.Actions>
213 </ComposerBanner.Row>
214 ))}
215 </ComposerBanner.Children>
216 </ComposerBanner.Scroll>
217 ) : null}
218 </div>
219 );
220});
221
222export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
223 onCollapse,
224 ...props
225}: Omit<ComponentProps<typeof ComposerTasksContent>, "expanded" | "onToggle"> & {
226 readonly onCollapse: () => void;
227}) {
228 return (
229 <ComposerBanner.Attachment>
230 <ComposerBanner.Root>
231 <ComposerTasksContent {...props} expanded onToggle={onCollapse} />
232 </ComposerBanner.Root>
233 </ComposerBanner.Attachment>
234 );
235});