1import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { Dispatch } from "react";
4import type { ComposerThreadTarget } from "../../composerDraftStore";
5import type { ComposerHandleRef } from "../../composerHandleContext";
6import {
7 isThreadWokeUnseen,
8 branchMismatchKey,
9 isBranchMismatchDismissedForSession,
10 shouldShowBranchMismatchBanner,
11 chatActionErrorMessage,
12 makeThreadErrorSetter,
13} from "../ChatView.logic";
14import { useStableCallback } from "../../hooks/useStableCallback";
15import { useDeadlinePassed, useNowAfter } from "../../hooks/useDeadline";
16import type { usePendingThreadRequests } from "./usePendingThreadRequests";
17import {
18 AuthOrchestrationOperateScope,
19 AuthSourceControlWriteScope,
20 type EnvironmentId,
21 type ScopedThreadRef,
22 type ThreadId,
23 ProviderInteractionMode,
24 RuntimeMode,
25} from "@t3tools/contracts";
26import {
27 effectiveSnoozed,
28 snoozeWakeLabel,
29 threadWokeAt,
30} from "@t3tools/client-runtime/state/thread-settled";
31import {
32 BACKGROUND_WORK_STOP_RETRY_AFTER_MS,
33 type BackgroundWorkStopRequest,
34 isBackgroundWorkStopPending,
35} from "@t3tools/client-runtime/state/thread-execution";
36import { useAcknowledgeThreadWoke, useThreadActions } from "../../hooks/useThreadActions";
37import { settleAndAdvance } from "../../settleAndAdvance";
38import type { ChatShortcutCommands } from "./useChatShortcuts";
39import { resolveThreadLastVisitedAt } from "../Sidebar.logic";
40import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
41import { type SetStateAction, useRef, useState } from "react";
42import { useNavigate, useRouter } from "@tanstack/react-router";
43import {
44 isAtomCommandInterrupted,
45 squashAtomCommandFailure,
46 type AtomCommandResult,
47} from "@t3tools/client-runtime/state/runtime";
48import { useUiStateStore } from "../../uiStateStore";
49import { type ChatMessage, DEFAULT_INTERACTION_MODE } from "../../types";
50import { AlarmClockIcon, CheckCircle2Icon } from "lucide-react";
51import { stackedThreadToast, toastManager } from "../ui/toast";
52import { titleRegenerationToast } from "../threadTitleRegenerationToast";
53import { newMessageId } from "~/lib/utils";
54import { buildThreadRouteParams } from "../../threadRoutes";
55import { composerDraftHasUserContent, useComposerDraftStore } from "../../composerDraftStore";
56import { useEnvironmentScope } from "~/state/session";
57import { threadEnvironment } from "../../state/threads";
58import { vcsEnvironment } from "../../state/vcs";
59import { useChildThreadInputs, readThreadShell, waitForThreadShell } from "../../state/entities";
60import { serverEnvironment } from "../../state/server";
61import { environmentShell } from "../../state/shell";
62import { ThreadStatusLine } from "./ThreadStatusLine";
63import { formatRelativeTimeLabel } from "../../timestampFormat";
64import { appAtomRegistry } from "../../rpc/atomRegistry";
65import { readEnvironmentScope } from "../../state/session";
66import { useAtomCommand } from "../../state/use-atom-command";
67import { useOrchestrationCommand } from "../../state/use-orchestration-command";
68import {
69 backgroundWorkNowItem,
70 childInputNowItem,
71 goalNowItem,
72 runStopNowItem,
73} from "./ThreadActivityBanners";
74import { useForceStopThread } from "./useForceStopThread";
75import { useAtomValue } from "@effect/atom-react";
76import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution";
77import { Atom } from "effect/reactivity";
78import { environmentThreadDetails } from "../../state/threads";
79import { openSideChatTab } from "../../lib/sideChatPane";
80import type { useLocalDispatchState } from "./useLocalDispatchState";
81const RELATED_THREAD_SHELL_WAIT_MS = 1_000;
82const NO_RUN_STOP_ATOM = Atom.make<RunStopPresentation | null>(null).pipe(
83 Atom.withLabel("web-thread-run-stop:empty"),
84);
85
86type PendingThreadRequests = ReturnType<typeof usePendingThreadRequests>;
87type LocalDispatchState = ReturnType<typeof useLocalDispatchState>;
88
89interface ThreadLifecycleInput {
90 readonly environmentId: EnvironmentId;
91 readonly routeThreadKey: string;
92 readonly currentRouteThreadKeyRef: { readonly current: string | null };
93 readonly canOperateThread: boolean;
94 readonly clientSettingsHydrated: boolean;
95 readonly activeThread: EnvironmentThreadShell | undefined;
96 readonly activeThreadId: ThreadId | null;
97 readonly activeThreadRef: ScopedThreadRef | null;
98 /** Null until the thread's detail may load; see resolveThreadDetailRef. */
99 readonly threadDetailRef: ScopedThreadRef | null;
100 readonly activeThreadKey: string | null;
101 readonly activeThreadShell: EnvironmentThreadShell | null;
102 /** The usage-limit recovery banner shows this snooze and its Wake now. */
103 readonly limitRecoveryHoldsSnooze: boolean;
104 readonly activeLatestRun: EnvironmentThreadShell["latestRun"];
105 readonly activeProjectCwd: string | null;
106 readonly serverConfig: EnvironmentServerConfig | null;
107 readonly runtimeMode: RuntimeMode;
108 readonly isWorking: boolean;
109 /** Also true while the server prepares, starts, or queues a run this client did not send. */
110 readonly runIsLive: boolean;
111 readonly pendingBackgroundTasks: Parameters<typeof backgroundWorkNowItem>[0]["tasks"];
112 readonly turnItems: Parameters<typeof backgroundWorkNowItem>[0]["turnItems"];
113 readonly activePendingApproval: PendingThreadRequests["activePendingApproval"];
114 readonly activePendingUserInput: PendingThreadRequests["activePendingUserInput"];
115 readonly localCheckoutBranchMismatch: {
116 readonly threadBranch: string;
117 readonly currentBranch: string;
118 } | null;
119 readonly gitStatusQuery: { readonly refresh: () => void };
120 readonly nowMinute: string;
121 readonly composerRef: ComposerHandleRef;
122 readonly composerDraftTarget: ComposerThreadTarget;
123 readonly sendInFlightRef: { current: boolean };
124 readonly setOptimisticUserMessages: Dispatch<SetStateAction<ChatMessage[]>>;
125 readonly setThreadError: ReturnType<typeof makeThreadErrorSetter>;
126 readonly clearUsageLimitsFor: (threadKey: string) => void;
127 readonly beginLocalDispatch: LocalDispatchState["beginLocalDispatch"];
128 readonly resetLocalDispatch: LocalDispatchState["resetLocalDispatch"];
129 readonly scrollToEnd: (animated?: boolean) => void;
130 readonly persistThreadSettingsForNextTurn: (input: {
131 threadId: ThreadId;
132 createdAt: string;
133 branch?: string;
134 runtimeMode: RuntimeMode;
135 interactionMode: ProviderInteractionMode;
136 }) => Promise<AtomCommandResult<void, unknown>>;
137}
138
139/**
140 * Thread state the chat shows and changes outside a normal send: snooze and settle with
141 * their status line, the local checkout's branch, background work, child-thread and goal
142 * banners, opening related threads, and standalone slash commands.
143 */
144export function useThreadLifecycle(input: ThreadLifecycleInput) {
145 const {
146 environmentId,
147 routeThreadKey,
148 currentRouteThreadKeyRef,
149 canOperateThread,
150 clientSettingsHydrated,
151 activeThread,
152 activeThreadId,
153 activeThreadRef,
154 threadDetailRef,
155 activeThreadKey,
156 activeThreadShell,
157 activeLatestRun,
158 limitRecoveryHoldsSnooze,
159 activeProjectCwd,
160 serverConfig,
161 runtimeMode,
162 isWorking,
163 runIsLive,
164 pendingBackgroundTasks,
165 turnItems,
166 activePendingApproval,
167 activePendingUserInput,
168 localCheckoutBranchMismatch,
169 gitStatusQuery,
170 nowMinute,
171 composerRef,
172 composerDraftTarget,
173 sendInFlightRef,
174 setOptimisticUserMessages,
175 setThreadError,
176 clearUsageLimitsFor,
177 beginLocalDispatch,
178 resetLocalDispatch,
179 scrollToEnd,
180 persistThreadSettingsForNextTurn,
181 } = input;
182 const navigate = useNavigate();
183 const router = useRouter();
184 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
185 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
186 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
187 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
188 reportFailure: false,
189 });
190 const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, {
191 reportFailure: false,
192 });
193 const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
194 reportFailure: false,
195 });
196 const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, {
197 reportFailure: false,
198 });
199
200 const serverConfigLoaded = serverConfig !== null;
201 const activeThreadPinned = serverConfigLoaded && activeThreadShell?.pinnedAt != null;
202 // A clock read in render would stay frozen in the compiler's cache, so the
203 // snooze checks read a snapshot that moves when the wake time changes or passes.
204 const snoozeClock = new Date(
205 useNowAfter(Date.parse(activeThreadShell?.snoozedUntil ?? "") || null),
206 ).toISOString();
207 const activeThreadSnoozed =
208 activeThreadShell !== null &&
209 serverConfigLoaded &&
210 effectiveSnoozed(activeThreadShell, { now: snoozeClock });
211 const activeThreadWokeAt =
212 activeThreadShell !== null && serverConfigLoaded
213 ? threadWokeAt(activeThreadShell, { now: snoozeClock })
214 : null;
215 const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
216 const acknowledgeActiveThreadWoke = () => {
217 if (activeThreadRef === null || activeThreadWokeAt === null) return;
218 acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
219 };
220 const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke);
221 const activeThreadLocalVisitedAt = useUiStateStore((store) =>
222 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
223 );
224 const activeThreadLastVisitedAt = resolveThreadLastVisitedAt(
225 activeThreadShell?.lastVisitedAt,
226 activeThreadLocalVisitedAt,
227 );
228 const activeThreadWokeVisible = isThreadWokeUnseen({
229 wokeAt: activeThreadWokeAt,
230 settled: activeThreadShell?.settledOverride === "settled",
231 lastVisitedAt: activeThreadLastVisitedAt,
232 latestRunCompletedAt: activeLatestRun?.completedAt ?? null,
233 });
234 const activeThreadSettled =
235 serverConfigLoaded && activeThreadShell?.settledOverride === "settled";
236 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
237 reportFailure: false,
238 });
239 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);
240 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
241 const handleUnsettleActiveThread = async () => {
242 if (
243 !activeThreadRef ||
244 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
245 )
246 return;
247 const threadKey = scopedThreadKey(activeThreadRef);
248 setUnsettlingThreadKey(threadKey);
249 // Commands settle into a result, so this always runs.
250 const result = await unsettleThreadMutation({
251 environmentId: activeThreadRef.environmentId,
252 input: { threadId: activeThreadRef.threadId, reason: "user" },
253 });
254 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
255 const error = squashAtomCommandFailure(result);
256 toastManager.add(
257 stackedThreadToast({
258 type: "error",
259 title: "Failed to un-settle thread",
260 description: error instanceof Error ? error.message : "An error occurred.",
261 }),
262 );
263 }
264 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
265 };
266 const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread);
267 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
268 reportFailure: false,
269 });
270 const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null);
271 const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;
272 const handleUnsnoozeActiveThread = async () => {
273 if (
274 !activeThreadRef ||
275 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
276 )
277 return;
278 const threadKey = scopedThreadKey(activeThreadRef);
279 setUnsnoozingThreadKey(threadKey);
280 // Commands settle into a result, so this always runs.
281 const recovery = activeThreadShell?.limitRecovery;
282 const recoveryOwnsSnooze =
283 recovery?.snooze === true &&
284 recovery.runId === activeThreadShell?.latestRun?.runId &&
285 activeThreadShell?.snoozedUntil != null &&
286 Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt);
287 const result = recoveryOwnsSnooze
288 ? await updateThreadMetadata({
289 environmentId: activeThreadRef.environmentId,
290 input: {
291 threadId: activeThreadRef.threadId,
292 limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false },
293 },
294 })
295 : await unsnoozeThreadMutation({
296 environmentId: activeThreadRef.environmentId,
297 input: { threadId: activeThreadRef.threadId, reason: "user" },
298 });
299 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
300 const error = squashAtomCommandFailure(result);
301 toastManager.add(
302 stackedThreadToast({
303 type: "error",
304 title: "Failed to wake thread",
305 description: error instanceof Error ? error.message : "An error occurred.",
306 }),
307 );
308 }
309 setUnsnoozingThreadKey((current) => (current === threadKey ? null : current));
310 };
311 const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread);
312 const [restoringBranchThreadKeys, setRestoringBranchThreadKeys] = useState<ReadonlySet<string>>(
313 () => new Set(),
314 );
315 const isRestoringThreadBranch =
316 activeThreadKey !== null && restoringBranchThreadKeys.has(activeThreadKey);
317 const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState<string | null>(null);
318 const [, setBranchMismatchDismissTick] = useState(0);
319 const composerHasDraftContent = useComposerDraftStore((store) =>
320 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
321 );
322 const activeBranchMismatchKey = branchMismatchKey(
323 activeThread?.id ?? null,
324 localCheckoutBranchMismatch,
325 );
326 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
327 hasMismatch: localCheckoutBranchMismatch !== null,
328 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
329 composerHasContent: composerHasDraftContent,
330 wasShownForCurrentMismatch:
331 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
332 });
333 const nextRevealedBranchMismatchKey = showBranchMismatchBanner
334 ? activeBranchMismatchKey
335 : revealedBranchMismatchKey !== activeBranchMismatchKey
336 ? null
337 : revealedBranchMismatchKey;
338 if (nextRevealedBranchMismatchKey !== revealedBranchMismatchKey) {
339 setRevealedBranchMismatchKey(nextRevealedBranchMismatchKey);
340 }
341 const handleSwitchCheckoutToThread = async () => {
342 if (!canWriteSourceControl) return;
343 if (
344 !activeProjectCwd ||
345 !activeThread ||
346 !localCheckoutBranchMismatch ||
347 isRestoringThreadBranch
348 ) {
349 return;
350 }
351 const threadKey = scopedThreadKey(scopeThreadRef(environmentId, activeThread.id));
352 const finishRestore = () =>
353 setRestoringBranchThreadKeys((current) => {
354 const next = new Set(current);
355 next.delete(threadKey);
356 return next;
357 });
358 setRestoringBranchThreadKeys((current) => new Set(current).add(threadKey));
359 const checkoutResult = await switchGitRef({
360 environmentId,
361 input: {
362 cwd: activeProjectCwd,
363 refName: localCheckoutBranchMismatch.threadBranch,
364 },
365 });
366 if (checkoutResult._tag === "Failure") {
367 finishRestore();
368 if (!isAtomCommandInterrupted(checkoutResult)) {
369 toastManager.add(
370 stackedThreadToast({
371 type: "error",
372 title: "Failed to switch checkout",
373 description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)),
374 }),
375 );
376 }
377 return;
378 }
379
380 if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) {
381 finishRestore();
382 return;
383 }
384 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;
385 if (nextBranch !== activeThread.branch) {
386 const updateResult = await updateThreadMetadata({
387 environmentId,
388 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },
389 });
390 if (updateResult._tag === "Failure") {
391 finishRestore();
392 if (!isAtomCommandInterrupted(updateResult)) {
393 toastManager.add(
394 stackedThreadToast({
395 type: "error",
396 title: "Checkout switched, but the thread could not be updated",
397 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
398 }),
399 );
400 }
401 gitStatusQuery.refresh();
402 return;
403 }
404 }
405 gitStatusQuery.refresh();
406 finishRestore();
407 if (currentRouteThreadKeyRef.current === routeThreadKey) {
408 window.requestAnimationFrame(() => {
409 if (currentRouteThreadKeyRef.current === routeThreadKey) composerRef.current?.focusAtEnd();
410 });
411 }
412 };
413 const handleSwitchCheckoutToThreadStable = useStableCallback(() => {
414 void handleSwitchCheckoutToThread();
415 });
416 const activeBackgroundTasks = activeThread ? pendingBackgroundTasks : [];
417 const [backgroundWorkStops, setBackgroundWorkStops] = useState<
418 Record<string, BackgroundWorkStopRequest | undefined>
419 >({});
420 const backgroundWorkStop = backgroundWorkStops[`${environmentId}:${activeThreadId}`] ?? null;
421 const isStoppingBackgroundWork = isBackgroundWorkStopPending(
422 backgroundWorkStop,
423 `${environmentId}:${activeThreadId}`,
424 pendingBackgroundTasks,
425 useDeadlinePassed(backgroundWorkStop?.retryAt ?? null),
426 );
427 const handleStopBackgroundWork = async () => {
428 if (
429 isStoppingBackgroundWork ||
430 !activeThread ||
431 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
432 )
433 return;
434 const request: BackgroundWorkStopRequest = {
435 threadKey: `${environmentId}:${activeThread.id}`,
436 taskIds: pendingBackgroundTasks.map((task) => task.taskId),
437 retryAt: null,
438 };
439 setBackgroundWorkStops((current) => ({ ...current, [request.threadKey]: request }));
440 const result = await interruptThreadTurn({
441 environmentId,
442 input: { threadId: activeThread.id },
443 });
444 setBackgroundWorkStops((current) =>
445 current[request.threadKey] !== request
446 ? current
447 : {
448 ...current,
449 [request.threadKey]:
450 result._tag === "Failure"
451 ? undefined
452 : { ...request, retryAt: Date.now() + BACKGROUND_WORK_STOP_RETRY_AFTER_MS },
453 },
454 );
455 if (result._tag === "Failure") {
456 if (!isAtomCommandInterrupted(result)) {
457 const error = squashAtomCommandFailure(result);
458 setThreadError(
459 activeThread.id,
460 error instanceof Error ? error.message : "Failed to stop background work.",
461 );
462 }
463 }
464 };
465 const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork);
466 const openRelatedThreadGenerationRef = useRef(0);
467 // A task delegated to a linked environment opens there; the default is this thread's own.
468 const onOpenRelatedThread = async (
469 threadId: ThreadId,
470 threadEnvironmentId: EnvironmentId = environmentId,
471 ) => {
472 const generation = ++openRelatedThreadGenerationRef.current;
473 const threadRef = scopeThreadRef(threadEnvironmentId, threadId);
474 const isEnvironmentLive = () =>
475 appAtomRegistry.get(environmentShell.stateValueAtom(threadEnvironmentId)).status === "live";
476 // The shell can leave idle subagent children out, so absence is not deletion there: the
477 // route decides from the thread's own detail.
478 const shellOmitsChildren =
479 appAtomRegistry.get(serverEnvironment.configValueAtom(threadEnvironmentId))
480 ?.shellThreadFamilies === true;
481 // A just-launched child can reach this timeline before its shell syncs.
482 if (!shellOmitsChildren && isEnvironmentLive() && readThreadShell(threadRef) === null) {
483 const location = router.latestLocation;
484 await waitForThreadShell(threadRef, RELATED_THREAD_SHELL_WAIT_MS);
485 if (
486 generation !== openRelatedThreadGenerationRef.current ||
487 currentRouteThreadKeyRef.current !== routeThreadKey ||
488 router.latestLocation !== location
489 ) {
490 return;
491 }
492 }
493 const shell = readThreadShell(threadRef);
494 if (shell === null ? !shellOmitsChildren && isEnvironmentLive() : shell.deletedAt != null) {
495 toastManager.add({ type: "info", title: "This thread is no longer available" });
496 return;
497 }
498 if (activeThreadRef && openSideChatTab(activeThreadRef, shell)) return;
499 void navigate({
500 to: "/$environmentId/$threadId",
501 params: buildThreadRouteParams(threadRef),
502 });
503 };
504 const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread);
505 const childThreadInputs = useChildThreadInputs(activeThreadRef);
506 const childInputNow = childInputNowItem({
507 childThreadInputs,
508 parentAwaitingUser: Boolean(
509 activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput,
510 ),
511 onOpenThread: onOpenRelatedThreadStable,
512 });
513
514 const sendStandaloneCommand = async (text: string, failureMessage: string) => {
515 if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return;
516 const context = composerRef.current?.getSendContext();
517 if (!context?.providerAvailable) return;
518
519 const threadId = activeThread.id;
520 const messageId = newMessageId();
521 const createdAt = new Date().toISOString();
522 sendInFlightRef.current = true;
523 beginLocalDispatch();
524 setThreadError(threadId, null);
525 setOptimisticUserMessages((messages) => [
526 ...messages,
527 {
528 id: messageId,
529 role: "user",
530 text,
531 runId: null,
532 createdAt,
533 updatedAt: createdAt,
534 streaming: false,
535 },
536 ]);
537 scrollToEnd();
538 // Commands settle into a result, so this always runs.
539 const settingsResult = await persistThreadSettingsForNextTurn({
540 threadId,
541 createdAt,
542 ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}),
543 runtimeMode,
544 interactionMode: DEFAULT_INTERACTION_MODE,
545 });
546 const result =
547 settingsResult._tag === "Failure"
548 ? settingsResult
549 : await startThreadTurn({
550 environmentId,
551 input: {
552 threadId,
553 message: { messageId, role: "user", text, attachments: [] },
554 modelSelection: context.selectedModelSelection,
555 runtimeMode,
556 interactionMode: DEFAULT_INTERACTION_MODE,
557 createdAt,
558 },
559 });
560 if (result._tag === "Failure") {
561 setOptimisticUserMessages((messages) =>
562 messages.filter((message) => message.id !== messageId),
563 );
564 resetLocalDispatch();
565 if (!isAtomCommandInterrupted(result)) {
566 const error = squashAtomCommandFailure(result);
567 setThreadError(threadId, error instanceof Error ? error.message : failureMessage);
568 }
569 } else {
570 clearUsageLimitsFor(routeThreadKey);
571 }
572 sendInFlightRef.current = false;
573 };
574 const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand);
575 const activeGoal = activeThreadShell?.goal ?? null;
576 const goalNow =
577 activeThread && activeGoal !== null
578 ? goalNowItem({
579 threadId: activeThread.id,
580 goal: activeGoal,
581 isWorking,
582 onSendCommand: sendStandaloneCommandStable,
583 })
584 : null;
585
586 const forceStop = useForceStopThread(environmentId);
587 const anyRunStop = useAtomValue(
588 threadDetailRef ? environmentThreadDetails.runStopAtom(threadDetailRef) : NO_RUN_STOP_ATOM,
589 );
590 // A background Stop matters only while that work is still pending.
591 const runStop = anyRunStop?.background && activeBackgroundTasks.length === 0 ? null : anyRunStop;
592 const runStopNow =
593 activeThread && runStop !== null
594 ? runStopNowItem({
595 threadId: activeThread.id,
596 stop: runStop,
597 canForceStop: forceStop.canForceStop,
598 forceStopping: forceStop.stoppingThreadId === activeThread.id,
599 onForceStop: () => void forceStop.forceStopThread(activeThread.id, runStop.runId),
600 })
601 : null;
602 const backgroundWorkNow = activeThread
603 ? backgroundWorkNowItem({
604 environmentId,
605 threadId: activeThread.id,
606 tasks: activeBackgroundTasks,
607 turnItems,
608 // Interrupt stops the active run first, not this work; the composer owns that Stop.
609 stopDisabled: runIsLive || !canOperateThread || isStoppingBackgroundWork,
610 stopping: isStoppingBackgroundWork,
611 onStop: handleStopBackgroundWorkStable,
612 onOpenThread: onOpenRelatedThreadStable,
613 })
614 : null;
615 // The relative times read the clock; nowMinute re-renders them on the minute.
616 const snoozedUntil = activeThreadShell?.snoozedUntil ?? null;
617 const settledAt = activeThreadShell?.settledAt ?? null;
618 // Same rounding as the sidebar's snoozed shelf, so both read the same hour.
619 const snoozeWake = snoozedUntil
620 ? snoozeWakeLabel(snoozedUntil, { now: `${nowMinute}:00.000Z` })
621 : null;
622 const threadStatusLine = activeThreadSnoozed ? (
623 limitRecoveryHoldsSnooze ? null : (
624 <ThreadStatusLine
625 icon={<AlarmClockIcon />}
626 label={snoozeWake && snoozeWake !== "now" ? `Snoozed, ${snoozeWake} left` : "Snoozed"}
627 clockMinute={nowMinute}
628 actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
629 actionDisabled={!canOperateThread || isUnsnoozing}
630 onAction={() => void handleUnsnoozeActiveThreadStable()}
631 />
632 )
633 ) : activeThreadSettled ? (
634 <ThreadStatusLine
635 icon={<CheckCircle2Icon />}
636 label={settledAt ? `Settled ${formatRelativeTimeLabel(settledAt)}` : "Settled"}
637 clockMinute={nowMinute}
638 actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"}
639 actionDisabled={!canOperateThread || isUnsettling}
640 onAction={() => void handleUnsettleActiveThreadStable()}
641 />
642 ) : activeThreadWokeVisible ? (
643 <ThreadStatusLine
644 icon={<AlarmClockIcon />}
645 label="Woke from snooze"
646 actionLabel="Dismiss"
647 onAction={acknowledgeActiveThreadWokeStable}
648 />
649 ) : null;
650 const toastThreadActionFailure =
651 (title: string) => (result: AtomCommandResult<unknown, unknown>) => {
652 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
653 const error = squashAtomCommandFailure(result);
654 toastManager.add(
655 stackedThreadToast({
656 type: "error",
657 title,
658 description: error instanceof Error ? error.message : "An error occurred.",
659 }),
660 );
661 };
662 const shortcuts = {
663 "thread.settle": () => {
664 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
665 if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return;
666 if (activeThreadSettled) {
667 void handleUnsettleActiveThread();
668 return;
669 }
670 if (settleAndAdvance(activeThreadRef)) return;
671 void settleThread(activeThreadRef).then(toastThreadActionFailure("Failed to settle thread"));
672 },
673 "thread.pin": ({ event }) => {
674 // An open project list pins its highlighted project with this key instead.
675 if (
676 event.target instanceof Element &&
677 event.target.closest('[role="combobox"][aria-expanded="true"]') !== null
678 ) {
679 return false;
680 }
681 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
682 if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return;
683 void (
684 activeThreadPinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)
685 ).then(
686 toastThreadActionFailure(
687 activeThreadPinned ? "Failed to unpin thread" : "Failed to pin thread",
688 ),
689 );
690 },
691 "thread.regenerateTitle": () => {
692 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
693 if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return;
694 if (activeThreadShell.titleRegeneration != null) return;
695 void regenerateThreadTitle({
696 environmentId: activeThreadRef.environmentId,
697 input: { threadId: activeThreadRef.threadId },
698 }).then((result) => {
699 const toast = titleRegenerationToast([result]);
700 if (toast) toastManager.add(toast);
701 });
702 },
703 } satisfies ChatShortcutCommands;
704 return {
705 shortcuts,
706 acknowledgeActiveThreadWoke,
707 activeBranchMismatchKey,
708 // A Stop that went unanswered needs the user; one in progress is the thread's work.
709 attentionNow: runStop?.state === "unresponsive" ? runStopNow : childInputNow,
710 workNow: runStop?.state === "stopping" ? runStopNow : backgroundWorkNow,
711 childInputNow,
712 goalNow,
713 handleSwitchCheckoutToThread,
714 handleSwitchCheckoutToThreadStable,
715 isRestoringThreadBranch,
716 onOpenRelatedThread,
717 onOpenRelatedThreadStable,
718 sendStandaloneCommand,
719 setBranchMismatchDismissTick,
720 showBranchMismatchBanner,
721 threadStatusLine,
722 };
723}