1import {
2 parseScopedThreadKey,
3 scopeProjectRef,
4 scopeThreadRef,
5 scopedThreadKey,
6} from "@t3tools/client-runtime/environment";
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
9import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
10import {
11 AuthOrchestrationOperateScope,
12 AuthSourceControlWriteScope,
13 EnvironmentAuthorizationError,
14 EnvironmentId,
15 type ScopedThreadRef,
16 ThreadId,
17 sessionGrantsScope,
18} from "@t3tools/contracts";
19import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
20import * as Cause from "effect/Cause";
21import * as Schema from "effect/Schema";
22import { AsyncResult } from "effect/reactivity";
23import { useRouter } from "@tanstack/react-router";
24
25import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
26import { useComposerDraftStore } from "../composerDraftStore";
27import { environmentSession, readEnvironmentScope } from "../state/session";
28import { appAtomRegistry } from "../rpc/atomRegistry";
29import { environmentServerConfigsAtom } from "../state/server";
30import { isScratchProject } from "@t3tools/client-runtime/state/projects";
31import { orchestrationEnvironment } from "../state/orchestration";
32import { projectEnvironment } from "../state/projects";
33import { threadEnvironment } from "../state/threads";
34import { vcsEnvironment } from "../state/vcs";
35import { useNewThreadHandler } from "./useHandleNewThread";
36import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
37import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
38import { announce } from "../lib/announce";
39import { readLocalApi } from "../localApi";
40import {
41 readEnvironmentServerConfigLoaded,
42 readEnvironmentSupportsAutoSettleOptOut,
43 readEnvironmentThreadRefs,
44 readProject,
45 readThreadShell,
46 readThreadShells,
47} from "../state/entities";
48import { useUiStateStore } from "../uiStateStore";
49import { useDiffPanelStore } from "../diffPanelStore";
50import { usePreviewMiniPlayerStore } from "../previewMiniPlayerStore";
51import { clearThreadPreviewState } from "../previewStateStore";
52import { useRightPanelStore } from "../rightPanelStore";
53import { useTerminalUiStateStore } from "../terminalUiStateStore";
54import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
56import { stackedThreadToast, toastManager } from "../components/ui/toast";
57import { useClientSettings } from "./useSettings";
58import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";
59import { showThreadUndoNotice } from "./showThreadUndoNotice";
60import { useAtomCommand } from "../state/use-atom-command";
61import { useOrchestrationCommand } from "../state/use-orchestration-command";
62import { useAtomQueryRunner } from "../state/use-atom-query-runner";
63
64export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
65 "ThreadArchiveBlockedError",
66 {
67 environmentId: EnvironmentId,
68 threadId: ThreadId,
69 },
70) {
71 override get message(): string {
72 return "Cannot archive while the provider is active.";
73 }
74}
75
76export class ThreadSettlementUnsupportedError extends Schema.TaggedError<ThreadSettlementUnsupportedError>()(
77 "ThreadSettlementUnsupportedError",
78 {
79 environmentId: EnvironmentId,
80 threadId: ThreadId,
81 },
82) {
83 override get message(): string {
84 return "This environment's server does not support settling yet. Update the server to use Settle.";
85 }
86}
87
88export class ThreadSnoozeUnsupportedError extends Schema.TaggedError<ThreadSnoozeUnsupportedError>()(
89 "ThreadSnoozeUnsupportedError",
90 {
91 environmentId: EnvironmentId,
92 threadId: ThreadId,
93 },
94) {
95 override get message(): string {
96 return "This environment's server does not support snoozing yet. Update the server to use Snooze.";
97 }
98}
99
100export class ThreadSnoozeBlockedError extends Schema.TaggedError<ThreadSnoozeBlockedError>()(
101 "ThreadSnoozeBlockedError",
102 {
103 environmentId: EnvironmentId,
104 threadId: ThreadId,
105 },
106) {
107 override get message(): string {
108 return "This thread is waiting on you. Respond to the pending request before snoozing it.";
109 }
110}
111
112function topOfPinnedRunOrderKey(): string | undefined {
113 let firstKey: string | null = null;
114 for (const shell of readThreadShells()) {
115 if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;
116 if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;
117 }
118 return pinOrderKeyBetween(null, firstKey) ?? undefined;
119}
120
121export class ThreadAutoSettleOptOutUnsupportedError extends Schema.TaggedError<ThreadAutoSettleOptOutUnsupportedError>()(
122 "ThreadAutoSettleOptOutUnsupportedError",
123 {
124 environmentId: EnvironmentId,
125 threadId: ThreadId,
126 },
127) {
128 override get message(): string {
129 return "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.";
130 }
131}
132
133export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(
134 "ThreadPinningUnsupportedError",
135 {
136 environmentId: EnvironmentId,
137 threadId: ThreadId,
138 },
139) {
140 override get message(): string {
141 return "This environment's server does not support pinning yet. Update the server to use Pin.";
142 }
143}
144
145export class ThreadPinReorderUnsupportedError extends Schema.TaggedError<ThreadPinReorderUnsupportedError>()(
146 "ThreadPinReorderUnsupportedError",
147 {
148 environmentId: EnvironmentId,
149 threadId: ThreadId,
150 },
151) {
152 override get message(): string {
153 return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins.";
154 }
155}
156
157export class ThreadActiveReorderUnsupportedError extends Schema.TaggedError<ThreadActiveReorderUnsupportedError>()(
158 "ThreadActiveReorderUnsupportedError",
159 {
160 environmentId: EnvironmentId,
161 threadId: ThreadId,
162 },
163) {
164 override get message(): string {
165 return "Update this environment's server to reorder active threads.";
166 }
167}
168
169async function requestThreadUnpinConfirmation(input: {
170 enabled: boolean;
171 title: string;
172 confirm: ((message: string) => Promise<boolean>) | null;
173}) {
174 const { confirm } = input;
175 if (!input.enabled || confirm === null) {
176 return AsyncResult.success(true);
177 }
178
179 return settlePromise(() =>
180 confirm(
181 [
182 `Unpin thread "${input.title}"?`,
183 "This will move the thread out of your pinned section.",
184 ].join("\n"),
185 ),
186 );
187}
188
189export async function navigateAfterThreadDeletion(navigate: () => Promise<void>) {
190 const result = await settlePromise(navigate);
191 if (result._tag === "Failure") {
192 const error = squashAtomCommandFailure(result);
193 toastManager.add(
194 stackedThreadToast({
195 type: "error",
196 title: "Thread deleted, but navigation failed",
197 description: error instanceof Error ? error.message : "An error occurred.",
198 }),
199 );
200 }
201}
202
203function useMarkThreadUnread() {
204 const markThreadUnreadMutation = useAtomCommand(threadEnvironment.markUnread, {
205 reportFailure: false,
206 });
207 const markThreadUnreadLocal = useUiStateStore((state) => state.markThreadUnread);
208 return (target: ScopedThreadRef) => {
209 if (readEnvironmentServerConfigLoaded(target.environmentId)) {
210 void markThreadUnreadMutation({
211 environmentId: target.environmentId,
212 input: { threadId: target.threadId },
213 });
214 return;
215 }
216 const thread = readThreadShell(target);
217 markThreadUnreadLocal(scopedThreadKey(target), thread?.latestRun?.completedAt);
218 };
219}
220
221export function useAcknowledgeThreadWoke() {
222 const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
223 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
224 return (target: ScopedThreadRef, wokeAt: string) => {
225 if (readEnvironmentServerConfigLoaded(target.environmentId)) {
226 void visitThreadMutation({
227 environmentId: target.environmentId,
228 input: { threadId: target.threadId, visitedAt: wokeAt },
229 });
230 return;
231 }
232 markThreadVisited(scopedThreadKey(target), wokeAt);
233 };
234}
235
236// These Undos restore a thread's place in the list, which archiving or deleting it ends.
237const LIST_PLACEMENT_UNDO_KINDS = ["settle", "snooze", "pin"];
238
239function expireThreadUndos(target: ScopedThreadRef, kinds: readonly string[]) {
240 for (const kind of kinds) ThreadUndo.invalidate(kind, scopedThreadKey(target));
241}
242
243function clearDeletedThreadPanelState(ref: ScopedThreadRef) {
244 clearThreadPreviewState(ref);
245 useRightPanelStore.getState().removeThread(ref);
246 useDiffPanelStore.getState().removeThread(ref);
247 usePreviewMiniPlayerStore.getState().removeThread(ref);
248}
249
250/** A No project thread that already has its own folder, on a server that can promote it. */
251export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean {
252 const thread = readThreadShell(threadRef);
253 const config = appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId);
254 if (!thread?.worktreePath || config?.environment.capabilities.threadProjectFromScratch !== true) {
255 return false;
256 }
257 const project = readProject({
258 environmentId: threadRef.environmentId,
259 projectId: thread.projectId,
260 });
261 return project !== null && isScratchProject(project, config.scratchWorkspaceRoot);
262}
263
264function threadOperationFailure(target: ScopedThreadRef) {
265 return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope)
266 ? null
267 : AsyncResult.failure(
268 Cause.fail(
269 new EnvironmentAuthorizationError({
270 message: "This connection cannot change threads.",
271 requiredScope: AuthOrchestrationOperateScope,
272 }),
273 ),
274 );
275}
276
277export function useThreadActions() {
278 const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, {
279 reportFailure: false,
280 });
281 const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, {
282 reportFailure: false,
283 });
284 const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, {
285 reportFailure: false,
286 });
287 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {
288 reportFailure: false,
289 });
290 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
291 reportFailure: false,
292 });
293 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {
294 reportFailure: false,
295 });
296 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {
297 reportFailure: false,
298 });
299 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {
300 reportFailure: false,
301 });
302 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {
303 reportFailure: false,
304 });
305 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {
306 reportFailure: false,
307 });
308 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {
309 reportFailure: false,
310 });
311 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
312 reportFailure: false,
313 });
314 const markThreadUnread = useMarkThreadUnread();
315 const createProjectFromThread = useAtomCommand(projectEnvironment.createFromThread, {
316 reportFailure: false,
317 });
318 const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession);
319 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
320 reportFailure: false,
321 });
322 const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, {
323 reportFailure: false,
324 });
325 const loadWorktreeThreadIds = useAtomQueryRunner(orchestrationEnvironment.worktreeThreadIds, {
326 reportFailure: false,
327 refresh: true,
328 });
329 const loadThreadFamily = useAtomQueryRunner(orchestrationEnvironment.threadFamily, {
330 reportFailure: false,
331 refresh: true,
332 });
333 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
334 reportFailure: false,
335 });
336 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
337 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
338 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
339 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
340 const clearProjectDraftThreadById = useComposerDraftStore(
341 (store) => store.clearProjectDraftThreadById,
342 );
343 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);
344 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
345 const router = useRouter();
346 const handleNewThread = useNewThreadHandler();
347
348 const resolveThreadTarget = (target: ScopedThreadRef) => {
349 const thread = readThreadShell(target);
350 if (!thread) {
351 return null;
352 }
353 return {
354 thread,
355 threadRef: target,
356 };
357 };
358 const getCurrentRouteThreadRef = () => {
359 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
360 return resolveThreadRouteRef(currentRouteParams);
361 };
362
363 const unarchiveThread = async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => {
364 ThreadUndo.invalidate("archive", scopedThreadKey(target));
365 const result = await unarchiveThreadMutation({
366 environmentId: target.environmentId,
367 input: { threadId: target.threadId },
368 });
369 if (result._tag === "Failure") {
370 return result;
371 }
372 refreshArchivedThreadsForEnvironment(target.environmentId);
373 if (opts.navigate) {
374 return settlePromise(() =>
375 router.navigate({
376 to: "/$environmentId/$threadId",
377 params: buildThreadRouteParams(target),
378 }),
379 );
380 }
381 return result;
382 };
383
384 const archiveThread = async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => {
385 const resolved = resolveThreadTarget(target);
386 if (!resolved) return AsyncResult.success(undefined);
387 const { thread, threadRef } = resolved;
388 if (!threadRuntimeCanArchive(thread.runtime)) {
389 return AsyncResult.failure(
390 Cause.fail(
391 new ThreadArchiveBlockedError({
392 environmentId: threadRef.environmentId,
393 threadId: threadRef.threadId,
394 }),
395 ),
396 );
397 }
398
399 const requestingRouteHref = router.state.location.href;
400 const currentRouteThreadRef = getCurrentRouteThreadRef();
401 const shouldNavigateToDraft =
402 currentRouteThreadRef?.threadId === threadRef.threadId &&
403 currentRouteThreadRef.environmentId === threadRef.environmentId;
404 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));
405 const archiveResult = await archiveThreadMutation({
406 environmentId: threadRef.environmentId,
407 input: { threadId: threadRef.threadId },
408 });
409 if (archiveResult._tag === "Failure") {
410 action.finish();
411 return archiveResult;
412 }
413 expireThreadUndos(threadRef, LIST_PLACEMENT_UNDO_KINDS);
414 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
415 if (wokeAt !== null) {
416 markThreadVisited(scopedThreadKey(threadRef), wokeAt);
417 }
418 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
419 opts.onArchived?.();
420 showThreadUndoNotice({
421 action: "Archived",
422 claim: action,
423 undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }),
424 failureTitle: "Failed to undo archive",
425 });
426
427 if (shouldNavigateToDraft && router.state.location.href === requestingRouteHref) {
428 const navigationResult = await settlePromise(() =>
429 handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)),
430 );
431 if (navigationResult._tag === "Failure") {
432 return navigationResult;
433 }
434 return archiveResult;
435 }
436
437 return archiveResult;
438 };
439
440 const deleteThread = async (
441 target: ScopedThreadRef,
442 opts: { deletedThreadKeys?: ReadonlySet<string> } = {},
443 ) => {
444 const permissionFailure = threadOperationFailure(target);
445 if (permissionFailure) return permissionFailure;
446 const resolved = resolveThreadTarget(target);
447 if (!resolved) {
448 let threads = readEnvironmentThreadRefs(target.environmentId).flatMap((ref) => {
449 const shell = readThreadShell(ref);
450 return shell === null ? [] : [{ id: shell.id, lineage: shell.lineage }];
451 });
452 if (
453 appAtomRegistry.get(environmentServerConfigsAtom).get(target.environmentId)
454 ?.shellThreadFamilies
455 ) {
456 const family = await loadThreadFamily({
457 environmentId: target.environmentId,
458 input: { threadId: target.threadId },
459 });
460 if (family._tag === "Success") threads = [...family.value.threads];
461 }
462 const result = await deleteThreadMutation({
463 environmentId: target.environmentId,
464 input: { threadId: target.threadId },
465 });
466 if (result._tag === "Success") {
467 for (const ref of [
468 target,
469 ...threads
470 .filter(
471 (entry) =>
472 entry.lineage.sideChat === true && entry.lineage.parentThreadId === target.threadId,
473 )
474 .map((entry) => scopeThreadRef(target.environmentId, entry.id)),
475 ]) {
476 releaseComposerDraftUploads(ref);
477 clearComposerDraftForThread(ref);
478 }
479 expireThreadUndos(target, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]);
480 refreshArchivedThreadsForEnvironment(target.environmentId);
481 clearTerminalUiState(target);
482 clearDeletedThreadPanelState(target);
483 }
484 return result;
485 }
486 const { thread, threadRef } = resolved;
487 const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => {
488 const shell = readThreadShell(ref);
489 return shell === null ? [] : [shell];
490 });
491 const threadProject = readProject({
492 environmentId: threadRef.environmentId,
493 projectId: thread.projectId,
494 });
495 const deletedIds =
496 opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
497 ? new Set<ThreadId>(
498 [...opts.deletedThreadKeys].flatMap((threadKey) => {
499 const ref = parseScopedThreadKey(threadKey);
500 return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : [];
501 }),
502 )
503 : undefined;
504 const survivingThreads =
505 deletedIds && deletedIds.size > 0
506 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))
507 : threads;
508 const environmentConfig = appAtomRegistry
509 .get(environmentServerConfigsAtom)
510 .get(threadRef.environmentId);
511 let orphanedWorktreePath = getOrphanedWorktreePathForThread(
512 survivingThreads,
513 threadRef.threadId,
514 );
515 // A shell that leaves idle subagent children out cannot prove nobody else uses the worktree,
516 // so the server's list decides. A failed read keeps the worktree.
517 if (orphanedWorktreePath !== null && environmentConfig?.shellThreadFamilies === true) {
518 const occupants = await loadWorktreeThreadIds({
519 environmentId: threadRef.environmentId,
520 input: { worktreePath: orphanedWorktreePath },
521 });
522 if (
523 occupants._tag !== "Success" ||
524 occupants.value.threadIds.some(
525 (threadId) => threadId !== threadRef.threadId && deletedIds?.has(threadId) !== true,
526 )
527 ) {
528 orphanedWorktreePath = null;
529 }
530 }
531 const displayWorktreePath = orphanedWorktreePath
532 ? formatWorktreePathForDisplay(orphanedWorktreePath)
533 : null;
534 const localApi = readLocalApi();
535 let canDeleteWorktree = false;
536 if (
537 orphanedWorktreePath !== null &&
538 threadProject !== null &&
539 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) &&
540 localApi
541 ) {
542 const sessionResult = await loadSessionState(threadRef.environmentId);
543 const permissionFailure = threadOperationFailure(threadRef);
544 if (permissionFailure) return permissionFailure;
545 canDeleteWorktree =
546 sessionResult._tag === "Success" &&
547 sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope);
548 }
549 let shouldDeleteWorktree = false;
550 const environmentSettings = environmentConfig?.settings;
551 const automaticWorktreeCleanup = environmentSettings
552 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete
553 : false;
554 if (canDeleteWorktree && localApi && !automaticWorktreeCleanup) {
555 const confirmationResult = await settlePromise(() =>
556 localApi.dialogs.confirm(
557 [
558 "This thread is the only one linked to this worktree:",
559 displayWorktreePath ?? orphanedWorktreePath,
560 "",
561 "Delete the worktree too?",
562 ].join("\n"),
563 { variant: "destructive" },
564 ),
565 );
566 if (confirmationResult._tag === "Failure") {
567 return confirmationResult;
568 }
569 shouldDeleteWorktree = confirmationResult.value;
570 }
571
572 if (thread.runtime !== null) {
573 await stopThreadSession({
574 environmentId: threadRef.environmentId,
575 input: { threadId: threadRef.threadId },
576 });
577 }
578
579 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
580 const currentRouteThreadRef = getCurrentRouteThreadRef();
581 const shouldNavigateToFallback =
582 currentRouteThreadRef?.threadId === threadRef.threadId &&
583 currentRouteThreadRef.environmentId === threadRef.environmentId;
584 const fallbackThreadId = getFallbackThreadIdAfterDelete({
585 threads,
586 deletedThreadId: threadRef.threadId,
587 deletedThreadIds,
588 sortOrder: sidebarThreadSortOrder,
589 now: new Date().toISOString(),
590 });
591 let navigatedDuringDelete = false;
592 // The missing-thread redirect and the index draft replace this entry; any other history
593 // action while the delete is pending is the user's own choice, which must win.
594 const stopWatchingHistory = router.history.subscribe(({ action }) => {
595 if (action.type !== "REPLACE") navigatedDuringDelete = true;
596 });
597 const deleteResult = await deleteThreadMutation({
598 environmentId: threadRef.environmentId,
599 input: { threadId: threadRef.threadId },
600 }).finally(stopWatchingHistory);
601 if (deleteResult._tag === "Failure") {
602 return deleteResult;
603 }
604 expireThreadUndos(threadRef, [...LIST_PLACEMENT_UNDO_KINDS, "archive"]);
605 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
606 releaseComposerDraftUploads(threadRef);
607 clearComposerDraftForThread(threadRef);
608 // The server deletes the thread's side chats with it.
609 for (const entry of threads) {
610 if (entry.lineage.sideChat !== true || entry.lineage.parentThreadId !== threadRef.threadId) {
611 continue;
612 }
613 const sideChatRef = scopeThreadRef(threadRef.environmentId, entry.id);
614 releaseComposerDraftUploads(sideChatRef);
615 clearComposerDraftForThread(sideChatRef);
616 }
617 clearProjectDraftThreadById(
618 scopeProjectRef(threadRef.environmentId, thread.projectId),
619 threadRef,
620 );
621 clearTerminalUiState(threadRef);
622 clearDeletedThreadPanelState(threadRef);
623
624 if (shouldNavigateToFallback && !navigatedDuringDelete) {
625 const fallbackThread = fallbackThreadId
626 ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId))
627 : null;
628 await navigateAfterThreadDeletion(() =>
629 fallbackThread
630 ? router.navigate({
631 to: "/$environmentId/$threadId",
632 params: buildThreadRouteParams(
633 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
634 ),
635 replace: true,
636 })
637 : router.navigate({ to: "/", replace: true }),
638 );
639 }
640
641 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
642 return deleteResult;
643 }
644
645 const removeResult = readEnvironmentScope(threadRef.environmentId, AuthSourceControlWriteScope)
646 ? await removeWorktree({
647 environmentId: threadRef.environmentId,
648 input: {
649 cwd: threadProject.workspaceRoot,
650 path: orphanedWorktreePath,
651 force: true,
652 },
653 })
654 : AsyncResult.failure(
655 Cause.fail(
656 new EnvironmentAuthorizationError({
657 message: "This connection can no longer remove worktrees.",
658 requiredScope: AuthSourceControlWriteScope,
659 }),
660 ),
661 );
662 const refreshResult =
663 removeResult._tag === "Success"
664 ? await refreshVcsStatus({
665 environmentId: threadRef.environmentId,
666 input: { cwd: threadProject.workspaceRoot },
667 })
668 : null;
669 const cleanupFailure =
670 removeResult._tag === "Failure"
671 ? removeResult
672 : refreshResult?._tag === "Failure"
673 ? refreshResult
674 : null;
675 if (cleanupFailure) {
676 const removalFailed = removeResult._tag === "Failure";
677 const error = squashAtomCommandFailure(cleanupFailure);
678 const message = error instanceof Error ? error.message : "An error occurred.";
679 console.error("Worktree cleanup failed after thread deletion", {
680 threadId: threadRef.threadId,
681 projectCwd: threadProject.workspaceRoot,
682 worktreePath: orphanedWorktreePath,
683 error,
684 });
685 toastManager.add(
686 stackedThreadToast({
687 type: "error",
688 title: removalFailed
689 ? "Failed to delete worktree"
690 : "Worktree deleted, but Git status refresh failed",
691 description: removalFailed
692 ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`
693 : message,
694 }),
695 );
696 // Cleanup has its own toast; reporting its failure would falsely imply deletion failed.
697 }
698 return deleteResult;
699 };
700
701 const unsettleThread = async (target: ScopedThreadRef) => {
702 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
703 return AsyncResult.failure(
704 Cause.fail(
705 new ThreadSettlementUnsupportedError({
706 environmentId: target.environmentId,
707 threadId: target.threadId,
708 }),
709 ),
710 );
711 }
712 ThreadUndo.invalidate("settle", scopedThreadKey(target));
713 // reason "user" pins active until real activity, suppressing auto-settle.
714 return unsettleThreadMutation({
715 environmentId: target.environmentId,
716 input: { threadId: target.threadId, reason: "user" },
717 });
718 };
719
720 const setThreadAutoSettle = async (target: ScopedThreadRef, enabled: boolean) => {
721 if (!readEnvironmentSupportsAutoSettleOptOut(target.environmentId)) {
722 return AsyncResult.failure(
723 Cause.fail(
724 new ThreadAutoSettleOptOutUnsupportedError({
725 environmentId: target.environmentId,
726 threadId: target.threadId,
727 }),
728 ),
729 );
730 }
731 return setThreadAutoSettleMutation({
732 environmentId: target.environmentId,
733 input: { threadId: target.threadId, enabled },
734 });
735 };
736
737 const sendPin = async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
738 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
739 return AsyncResult.failure(
740 Cause.fail(
741 new ThreadPinningUnsupportedError({
742 environmentId: target.environmentId,
743 threadId: target.threadId,
744 }),
745 ),
746 );
747 }
748 const orderKey = opts.orderKey ?? topOfPinnedRunOrderKey();
749 ThreadUndo.invalidate("pin", scopedThreadKey(target));
750 // Pinning clears snooze, so older Settled or Snoozed undos would fight this pin.
751 ThreadUndo.invalidate("settle", scopedThreadKey(target));
752 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
753 return pinThreadMutation({
754 environmentId: target.environmentId,
755 input: {
756 threadId: target.threadId,
757 ...(orderKey !== undefined ? { orderKey } : {}),
758 },
759 });
760 };
761
762 const pinThread = async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
763 const result = await sendPin(target, opts);
764 if (result._tag === "Success") announce("Pinned 1 thread");
765 return result;
766 };
767
768 const unpinThread = async (target: ScopedThreadRef) => {
769 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
770 return AsyncResult.failure(
771 Cause.fail(
772 new ThreadPinningUnsupportedError({
773 environmentId: target.environmentId,
774 threadId: target.threadId,
775 }),
776 ),
777 );
778 }
779 const thread = readThreadShell(target);
780 const undoPlan = ThreadUndo.planThreadUndo("unpin", thread);
781 const action = ThreadUndo.begin("pin", scopedThreadKey(target));
782 const result = await unpinThreadMutation({
783 environmentId: target.environmentId,
784 input: { threadId: target.threadId },
785 });
786 if (result._tag === "Success" && action.isCurrent()) {
787 showThreadUndoNotice({
788 action: "Unpinned",
789 claim: action,
790 undo: () => runUndoPlan(target, undoPlan),
791 failureTitle: "Failed to undo unpin",
792 });
793 } else {
794 action.finish();
795 }
796 return result;
797 };
798
799 const settleThread = async (target: ScopedThreadRef) => {
800 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
801 return AsyncResult.failure(
802 Cause.fail(
803 new ThreadSettlementUnsupportedError({
804 environmentId: target.environmentId,
805 threadId: target.threadId,
806 }),
807 ),
808 );
809 }
810 const resolved = resolveThreadTarget(target);
811 // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
812 const nowIso = new Date().toISOString();
813 const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: nowIso }) : null;
814 const undoPlan = ThreadUndo.planThreadUndo("settle", resolved?.thread);
815 // An older unpin/snooze Undo would re-pin or re-snooze, and the server
816 // treats either as a promotion that un-settles; settling supersedes them.
817 ThreadUndo.invalidate("pin", scopedThreadKey(target));
818 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
819 const action = ThreadUndo.begin("settle", scopedThreadKey(target));
820 const result = await settleThreadMutation({
821 environmentId: target.environmentId,
822 input: { threadId: target.threadId },
823 });
824 if (result._tag !== "Success") {
825 action.finish();
826 return result;
827 }
828 if (wokeAt !== null) {
829 markThreadVisited(scopedThreadKey(target), wokeAt);
830 }
831 showThreadUndoNotice({
832 action: "Settled",
833 claim: action,
834 undo: () => runUndoPlan(target, undoPlan),
835 failureTitle: "Failed to undo settle",
836 });
837 return result;
838 };
839
840 const confirmAndUnpinThread = async (target: ScopedThreadRef) => {
841 const permissionFailure = threadOperationFailure(target);
842 if (permissionFailure) return permissionFailure;
843 const localApi = readLocalApi();
844 const resolved = resolveThreadTarget(target);
845 const confirmationResult = await requestThreadUnpinConfirmation({
846 enabled: confirmThreadUnpin,
847 title: resolved?.thread.title ?? "this thread",
848 confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null,
849 });
850 if (confirmationResult._tag === "Failure") {
851 return confirmationResult;
852 }
853 if (!confirmationResult.value) {
854 return AsyncResult.success(undefined);
855 }
856 return unpinThread(target);
857 };
858
859 const reorderPinnedThread = async (target: ScopedThreadRef, orderKey: string) => {
860 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
861 return AsyncResult.failure(
862 Cause.fail(
863 new ThreadPinReorderUnsupportedError({
864 environmentId: target.environmentId,
865 threadId: target.threadId,
866 }),
867 ),
868 );
869 }
870 ThreadUndo.invalidate("pin", scopedThreadKey(target));
871 return reorderPinnedThreadMutation({
872 environmentId: target.environmentId,
873 input: { threadId: target.threadId, orderKey },
874 });
875 };
876
877 const reorderActiveThread = async (target: ScopedThreadRef, orderKey: string) => {
878 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
879 return AsyncResult.failure(
880 Cause.fail(
881 new ThreadActiveReorderUnsupportedError({
882 environmentId: target.environmentId,
883 threadId: target.threadId,
884 }),
885 ),
886 );
887 }
888 return reorderActiveThreadMutation({
889 environmentId: target.environmentId,
890 input: { threadId: target.threadId, orderKey },
891 });
892 };
893
894 const unsnoozeThread = async (target: ScopedThreadRef) => {
895 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
896 return AsyncResult.failure(
897 Cause.fail(
898 new ThreadSnoozeUnsupportedError({
899 environmentId: target.environmentId,
900 threadId: target.threadId,
901 }),
902 ),
903 );
904 }
905 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
906 return unsnoozeThreadMutation({
907 environmentId: target.environmentId,
908 input: { threadId: target.threadId, reason: "user" },
909 });
910 };
911
912 const snoozeThread = async (target: ScopedThreadRef, snoozedUntil: string) => {
913 if (!readEnvironmentServerConfigLoaded(target.environmentId)) {
914 return AsyncResult.failure(
915 Cause.fail(
916 new ThreadSnoozeUnsupportedError({
917 environmentId: target.environmentId,
918 threadId: target.threadId,
919 }),
920 ),
921 );
922 }
923 const resolved = resolveThreadTarget(target);
924 // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
925 if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) {
926 return AsyncResult.failure(
927 Cause.fail(
928 new ThreadSnoozeBlockedError({
929 environmentId: resolved.threadRef.environmentId,
930 threadId: resolved.threadRef.threadId,
931 }),
932 ),
933 );
934 }
935 const action = ThreadUndo.begin("snooze", scopedThreadKey(target));
936 const result = await snoozeThreadMutation({
937 environmentId: target.environmentId,
938 input: { threadId: target.threadId, snoozedUntil },
939 });
940 if (result._tag !== "Success") {
941 action.finish();
942 return result;
943 }
944 showThreadUndoNotice({
945 action: "Snoozed",
946 claim: action,
947 undo: () => runUndoPlan(target, ThreadUndo.planThreadUndo("snooze")),
948 failureTitle: "Failed to wake thread",
949 });
950 return result;
951 };
952
953 const runUndoPlan = (target: ScopedThreadRef, plan: ThreadUndo.ThreadUndoPlan) =>
954 ThreadUndo.executeThreadUndo(
955 plan,
956 async (step) => {
957 switch (step.action) {
958 case "unsettle":
959 return unsettleThread(target);
960 case "unsnooze":
961 return unsnoozeThread(target);
962 case "pin":
963 return sendPin(target, step.orderKey === undefined ? {} : { orderKey: step.orderKey });
964 case "snooze":
965 return snoozeThreadMutation({
966 environmentId: target.environmentId,
967 input: { threadId: target.threadId, snoozedUntil: step.snoozedUntil },
968 });
969 }
970 },
971 (result) => result._tag === "Success",
972 );
973
974 const confirmAndDeleteThread = async (target: ScopedThreadRef, knownTitle?: string) => {
975 const permissionFailure = threadOperationFailure(target);
976 if (permissionFailure) return permissionFailure;
977 const localApi = readLocalApi();
978 const resolved = resolveThreadTarget(target);
979
980 if (confirmThreadDelete && localApi) {
981 const title = resolved?.thread.title ?? knownTitle ?? "this thread";
982 const confirmationResult = await settlePromise(() =>
983 localApi.dialogs.confirm(
984 [
985 `Delete thread "${title}"?`,
986 "This permanently clears conversation history for this thread.",
987 ].join("\n"),
988 { variant: "destructive" },
989 ),
990 );
991 if (confirmationResult._tag === "Failure") {
992 return confirmationResult;
993 }
994 if (!confirmationResult.value) {
995 return AsyncResult.success(undefined);
996 }
997 }
998
999 return deleteThread(target);
1000 };
1001
1002 return {
1003 archiveThread,
1004 unarchiveThread,
1005 deleteThread,
1006 confirmAndDeleteThread,
1007 settleThread,
1008 unsettleThread,
1009 snoozeThread,
1010 unsnoozeThread,
1011 pinThread,
1012 unpinThread,
1013 confirmAndUnpinThread,
1014 reorderPinnedThread,
1015 reorderActiveThread,
1016 markThreadUnread,
1017 setThreadAutoSettle,
1018 turnThreadIntoProject: (target: ScopedThreadRef) =>
1019 createProjectFromThread({
1020 environmentId: target.environmentId,
1021 input: { threadId: target.threadId },
1022 }),
1023 };
1024}