1import { memo, type MouseEventHandler, type PointerEventHandler } from "react";
2import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, Minimize2Icon, PlayIcon } from "lucide-react";
3import { CornerUpRight, ListPlus } from "lucide";
4import { MorphIcon } from "~/components/MorphIcon";
5import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
6import { formatContextWindowTokens } from "~/lib/contextWindow";
7import { cn } from "~/lib/utils";
8import { useShortcutModifierState } from "../../shortcutModifierState";
9import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
10import { Button } from "../ui/button";
11import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
12import { Spinner } from "../ui/spinner";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import { composerFloatingLayerProps } from "./composerEventScope";
15import {
16 alternateComposerDispatchAction,
17 resolveComposerDispatchMode,
18} from "@t3tools/client-runtime/state/composer-dispatch";
19
20interface PendingActionState {
21 questionIndex: number;
22 isLastQuestion: boolean;
23 canAdvance: boolean;
24 isResponding: boolean;
25 isComplete: boolean;
26}
27
28interface ComposerPrimaryActionsProps {
29 compact: boolean;
30 canOperateThread: boolean;
31 pendingAction: PendingActionState | null;
32 /** The turn is running: sending steers or queues instead of starting a turn. */
33 isRunning: boolean;
34 /** Stop can reach a run, including one still preparing or starting. */
35 canInterrupt: boolean;
36 followUpBehavior?: "queue" | "steer";
37 alternateShortcutLabel?: string | null;
38 showPlanFollowUpPrompt: boolean;
39 promptHasText: boolean;
40 isSendBusy: boolean;
41 sendDisabledReason: string | null;
42 isConnecting: boolean;
43 isEnvironmentUnavailable: boolean;
44 isPreparingWorktree: boolean;
45 hasSendableContent: boolean;
46 canResume?: boolean;
47 preserveComposerFocusOnPointerDown?: boolean;
48 isEditingQueuedMessage?: boolean;
49 onSubmitMessage?: MouseEventHandler<HTMLButtonElement>;
50 onResume?: () => void;
51 onPreviousPendingQuestion: () => void;
52 onInterrupt: () => void;
53 onImplementPlanInNewThread: () => void;
54 /** Tokens a stale session would re-read. When set, a Compact chip shows the count and Enter compacts first. */
55 compactBeforeSendTokens?: number | null;
56 /** The Compact chip is turned off, so the next send keeps full history. */
57 keepFullHistory?: boolean;
58 onToggleKeepFullHistory?: () => void;
59}
60
61const formatPendingPrimaryActionLabel = (input: {
62 compact: boolean;
63 isLastQuestion: boolean;
64 isResponding: boolean;
65 questionIndex: number;
66}) => {
67 if (input.isResponding) {
68 return "Submitting...";
69 }
70 if (input.compact) {
71 return input.isLastQuestion ? "Submit" : "Next";
72 }
73 if (!input.isLastQuestion) {
74 return "Next question";
75 }
76 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
77};
78
79// The composer's labeled primary actions (Submit, Refine, Implement) share the send button's
80// message-action pill, so they are composer-owned buttons rather than restyled Buttons.
81const messageActionPillClassName =
82 "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm";
83
84const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
85 event.preventDefault();
86};
87
88export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
89 compact,
90 canOperateThread,
91 pendingAction,
92 isRunning,
93 canInterrupt,
94 followUpBehavior = "steer",
95 alternateShortcutLabel = null,
96 showPlanFollowUpPrompt,
97 promptHasText,
98 isSendBusy,
99 sendDisabledReason,
100 isConnecting,
101 isEnvironmentUnavailable,
102 isPreparingWorktree,
103 hasSendableContent,
104 canResume = false,
105 preserveComposerFocusOnPointerDown = false,
106 isEditingQueuedMessage = false,
107 onSubmitMessage,
108 onResume,
109 onPreviousPendingQuestion,
110 onInterrupt,
111 onImplementPlanInNewThread,
112 compactBeforeSendTokens = null,
113 keepFullHistory = false,
114 onToggleKeepFullHistory,
115}: ComposerPrimaryActionsProps) {
116 const pointerFocusProps = preserveComposerFocusOnPointerDown
117 ? { onPointerDown: preventPointerFocus }
118 : undefined;
119 const environmentIdentificationMode = useEnvironmentIdentificationMode();
120 const shortcutModifiers = useShortcutModifierState();
121 const isQueuing =
122 !isEditingQueuedMessage &&
123 resolveComposerDispatchMode({
124 running: isRunning,
125 activeTurnDefault: followUpBehavior,
126 alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey,
127 }) === "queue";
128 const alternateAction = alternateComposerDispatchAction(followUpBehavior);
129 const isSendDisabled = !canOperateThread || sendDisabledReason !== null;
130 const stageBackdropVariant = useSidebarStageBackdropVariant(
131 environmentIdentificationMode === "artwork",
132 );
133
134 const renderStopGenerationButton = (insidePendingAction: boolean) => (
135 <Tooltip key="interrupt">
136 <TooltipTrigger
137 render={
138 <button
139 type="button"
140 className={cn(
141 "flex shrink-0 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-control-highlight transition-colors duration-150 hover:bg-destructive active:inset-shadow-control-pressed active:shadow-none [&_svg]:pointer-events-none",
142 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",
143 )}
144 {...pointerFocusProps}
145 disabled={!canOperateThread}
146 onClick={() => {
147 if (canOperateThread) onInterrupt();
148 }}
149 aria-label="Stop generation"
150 />
151 }
152 >
153 <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
154 <rect x="2" y="2" width="8" height="8" rx="1.5" />
155 </svg>
156 </TooltipTrigger>
157 <TooltipPopup>Interrupt</TooltipPopup>
158 </Tooltip>
159 );
160
161 if (pendingAction) {
162 return (
163 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
164 {canInterrupt ? renderStopGenerationButton(true) : null}
165 {pendingAction.questionIndex > 0 ? (
166 compact ? (
167 <Button
168 size="icon-sm"
169 variant="outline"
170 {...pointerFocusProps}
171 onClick={onPreviousPendingQuestion}
172 disabled={pendingAction.isResponding}
173 aria-label="Previous question"
174 >
175 <ChevronLeftIcon className="size-3.5" />
176 </Button>
177 ) : (
178 <Button
179 size="sm"
180 variant="outline"
181 {...pointerFocusProps}
182 onClick={onPreviousPendingQuestion}
183 disabled={pendingAction.isResponding}
184 >
185 Previous
186 </Button>
187 )
188 ) : null}
189 <button
190 type="submit"
191 className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")}
192 {...pointerFocusProps}
193 disabled={
194 !canOperateThread ||
195 isEnvironmentUnavailable ||
196 pendingAction.isResponding ||
197 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
198 }
199 >
200 {formatPendingPrimaryActionLabel({
201 compact,
202 isLastQuestion: pendingAction.isLastQuestion,
203 isResponding: pendingAction.isResponding,
204 questionIndex: pendingAction.questionIndex,
205 })}
206 </button>
207 </div>
208 );
209 }
210
211 if (showPlanFollowUpPrompt && (promptHasText || !canResume)) {
212 if (promptHasText) {
213 return (
214 <button
215 type="submit"
216 className={cn(messageActionPillClassName, "h-9 sm:h-8", compact ? "px-3" : "px-4")}
217 {...pointerFocusProps}
218 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
219 >
220 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
221 </button>
222 );
223 }
224
225 return (
226 <div data-chat-composer-implement-actions="true" className="flex items-center justify-end">
227 <button
228 type="submit"
229 className={cn(messageActionPillClassName, "h-9 rounded-r-none px-4 sm:h-8")}
230 {...pointerFocusProps}
231 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
232 >
233 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
234 </button>
235 <Menu>
236 <MenuTrigger
237 render={
238 <button
239 type="button"
240 className={cn(
241 messageActionPillClassName,
242 "h-9 rounded-l-none border-l border-message-action-foreground/20 px-2 sm:h-8",
243 )}
244 aria-label="Implementation actions"
245 {...pointerFocusProps}
246 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
247 />
248 }
249 >
250 <ChevronDownIcon className="size-3.5" />
251 </MenuTrigger>
252 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
253 <MenuItem
254 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
255 onClick={() => {
256 if (canOperateThread) onImplementPlanInNewThread();
257 }}
258 >
259 Implement in a new thread
260 </MenuItem>
261 </MenuPopup>
262 </Menu>
263 </div>
264 );
265 }
266
267 if (canInterrupt && !hasSendableContent && !isEditingQueuedMessage) {
268 return renderStopGenerationButton(false);
269 }
270
271 const showResume = canResume && !hasSendableContent && !isEditingQueuedMessage;
272
273 const compactTokens =
274 compactBeforeSendTokens !== null && !showResume && !isEditingQueuedMessage
275 ? formatContextWindowTokens(compactBeforeSendTokens)
276 : null;
277 const compactsBeforeSend = compactTokens !== null && !keepFullHistory;
278
279 const submitLabel = showResume
280 ? "Resume thread"
281 : isEditingQueuedMessage
282 ? "Update queued message"
283 : compactsBeforeSend
284 ? "Compact and send"
285 : isQueuing
286 ? "Queue message"
287 : isRunning
288 ? "Steer message"
289 : "Submit message";
290 const submitStatus = isEnvironmentUnavailable
291 ? "Environment disconnected"
292 : (sendDisabledReason ??
293 (isConnecting
294 ? "Connecting"
295 : isPreparingWorktree
296 ? "Preparing worktree"
297 : isSendBusy
298 ? isEditingQueuedMessage
299 ? "Updating queued message"
300 : "Submitting message"
301 : null));
302 const submitTooltip =
303 submitStatus ??
304 (compactsBeforeSend
305 ? `Summarize ${compactTokens} tokens of history, then send`
306 : isRunning && !isEditingQueuedMessage
307 ? `Click to ${followUpBehavior}, Ctrl/⌘-click${alternateShortcutLabel ? ` or ${alternateShortcutLabel}` : ""} to ${alternateAction}`
308 : submitLabel);
309
310 const sendButton = (
311 <button
312 type={showResume ? "button" : "submit"}
313 className={cn(
314 "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-control-highlight hover:scale-105 active:inset-shadow-control-pressed active:shadow-none disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8 [&_svg]:pointer-events-none",
315 stageBackdropVariant
316 ? "bg-transparent text-white enabled:shadow-black/24 enabled:hover:brightness-110"
317 : "bg-message-action text-message-action-foreground enabled:shadow-message-action/24 hover:bg-message-action-hover",
318 )}
319 {...pointerFocusProps}
320 onClick={showResume ? onResume : onSubmitMessage}
321 disabled={
322 isSendBusy ||
323 isSendDisabled ||
324 isConnecting ||
325 isEnvironmentUnavailable ||
326 (!hasSendableContent && !showResume)
327 }
328 aria-label={submitStatus ?? submitLabel}
329 >
330 {stageBackdropVariant ? (
331 <span className="pointer-events-none absolute inset-0 -z-10" aria-hidden="true">
332 <StageBackdropButtonArt variant={stageBackdropVariant} />
333 </span>
334 ) : null}
335 {isConnecting || isSendBusy ? (
336 <Spinner size="sm" aria-hidden="true" />
337 ) : showResume ? (
338 <PlayIcon className="size-4 fill-current" aria-hidden="true" />
339 ) : isEditingQueuedMessage ? (
340 <CheckIcon className="size-4" aria-hidden="true" />
341 ) : isRunning ? (
342 <MorphIcon className="size-4" icon={isQueuing ? ListPlus : CornerUpRight} />
343 ) : (
344 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
345 <path
346 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
347 stroke="currentColor"
348 strokeWidth="1.8"
349 strokeLinecap="round"
350 strokeLinejoin="round"
351 />
352 </svg>
353 )}
354 </button>
355 );
356
357 const submit = (
358 <Tooltip key="submit">
359 <TooltipTrigger render={<span className="inline-flex" />}>{sendButton}</TooltipTrigger>
360 <TooltipPopup>{submitTooltip}</TooltipPopup>
361 </Tooltip>
362 );
363 if (compactTokens === null) return submit;
364
365 return (
366 <div data-chat-composer-compact-send="true" className="flex items-center gap-2">
367 <Tooltip>
368 <TooltipTrigger
369 render={
370 <button
371 type="button"
372 className={cn(
373 "flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 sm:h-8 [&_svg]:pointer-events-none [&_svg]:size-3.5",
374 keepFullHistory
375 ? "border-border text-muted-foreground hover:text-foreground"
376 : "border-warning/40 text-warning hover:bg-warning/8",
377 )}
378 {...pointerFocusProps}
379 aria-pressed={!keepFullHistory}
380 aria-label={`Compact ${compactTokens} tokens of history before sending`}
381 disabled={!canOperateThread}
382 onClick={onToggleKeepFullHistory}
383 />
384 }
385 >
386 <Minimize2Icon aria-hidden="true" />
387 {keepFullHistory ? "Full" : "Compact"}
388 <span>{compactTokens}</span>
389 </TooltipTrigger>
390 <TooltipPopup>
391 {keepFullHistory
392 ? `Next send keeps all ${compactTokens} tokens. Click to compact first`
393 : `Next send compacts ${compactTokens} tokens first. Click to keep full history`}
394 </TooltipPopup>
395 </Tooltip>
396 {submit}
397 </div>
398 );
399});