1import { environmentCatalog } from "../connection/catalog";
2import { hubConnection } from "../connection/hub";
3import {
4 parseScopedThreadKey,
5 scopeProjectRef,
6 scopeThreadRef,
7 scopedThreadKey,
8} from "@t3tools/client-runtime/environment";
9import {
10 executeAtomQuery,
11 settlePromise,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import {
15 canSnooze,
16 threadWokeAt,
17 resolveThreadSettlementWarning,
18} from "@t3tools/client-runtime/state/thread-settled";
19import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models";
20import {
21 AuthOrchestrationOperateScope,
22 AuthSourceControlWriteScope,
23 EnvironmentAuthorizationError,
24 EnvironmentId,
25 type ScopedThreadRef,
26 ThreadId,
27 sessionGrantsScope,
28} from "@t3tools/contracts";
29import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
30import * as Cause from "effect/Cause";
31import * as Option from "effect/Option";
32import * as Schema from "effect/Schema";
33import { AsyncResult } from "effect/reactivity";
34import { useRouter } from "@tanstack/react-router";
35import { useCallback, useMemo, useRef } from "react";
36
37import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
38import { useComposerDraftStore } from "../composerDraftStore";
39import {
40 environmentSession,
41 readEnvironmentScope,
42 readThreadOrganizationPermission,
43} from "../state/session";
44import { appAtomRegistry } from "../rpc/atomRegistry";
45import { environmentServerConfigsAtom } from "../state/server";
46import { isScratchProject } from "@t3tools/client-runtime/state/projects";
47import { threadEnvironment } from "../state/threads";
48import { vcsEnvironment } from "../state/vcs";
49import { terminalEnvironment } from "../state/terminal";
50import { previewEnvironment } from "../state/preview";
51import { useNewThreadHandler } from "./useHandleNewThread";
52import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
53import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
54import { readLocalApi } from "../localApi";
55import {
56 readEnvironmentSupportsAutoSettleOptOut,
57 readEnvironmentSupportsPinning,
58 readEnvironmentSupportsPinReorder,
59 readEnvironmentSupportsActiveReorder,
60 readEnvironmentSupportsSettlement,
61 readEnvironmentSupportsSnooze,
62 readEnvironmentSupportsVisitedTracking,
63 readEnvironmentThreadRefs,
64 readProject,
65 readThreadShell,
66 readThreadShells,
67} from "../state/entities";
68import { useUiStateStore } from "../uiStateStore";
69import { clearThreadPreviewState } from "../previewStateStore";
70import { useTerminalUiStateStore } from "../terminalUiStateStore";
71import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
72import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
73import { stackedThreadToast, toastManager } from "../components/ui/toast";
74import { useClientSettings } from "./useSettings";
75import * as ThreadUndo from "./threadUndo";
76import { showThreadUndoNotice } from "./showThreadUndoNotice";
77import { useAtomCommand } from "../state/use-atom-command";
78import { useOrchestrationCommand } from "../state/use-orchestration-command";
79import { useAtomQueryRunner } from "../state/use-atom-query-runner";
80
81export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()(
82 "ThreadArchiveBlockedError",
83 {
84 environmentId: EnvironmentId,
85 threadId: ThreadId,
86 },
87) {
88 override get message(): string {
89 return "Cannot archive while the provider is active.";
90 }
91}
92
93export class ThreadSettlementUnsupportedError extends Schema.TaggedError<ThreadSettlementUnsupportedError>()(
94 "ThreadSettlementUnsupportedError",
95 {
96 environmentId: EnvironmentId,
97 threadId: ThreadId,
98 },
99) {
100 override get message(): string {
101 return "This environment's server does not support settling yet. Update the server to use Settle.";
102 }
103}
104
105export class ThreadSnoozeUnsupportedError extends Schema.TaggedError<ThreadSnoozeUnsupportedError>()(
106 "ThreadSnoozeUnsupportedError",
107 {
108 environmentId: EnvironmentId,
109 threadId: ThreadId,
110 },
111) {
112 override get message(): string {
113 return "This environment's server does not support snoozing yet. Update the server to use Snooze.";
114 }
115}
116
117export class ThreadSnoozeBlockedError extends Schema.TaggedError<ThreadSnoozeBlockedError>()(
118 "ThreadSnoozeBlockedError",
119 {
120 environmentId: EnvironmentId,
121 threadId: ThreadId,
122 },
123) {
124 override get message(): string {
125 return "This thread is waiting on you. Respond to the pending request before snoozing it.";
126 }
127}
128
129/** Key that sorts before every arranged pinned thread, so a fresh pin lands
130 at the top of the run. Undefined (keyless, sorts with the legacy block)
131 when key math can't produce one — pinning must never fail on placement. */
132function topOfPinnedRunOrderKey(): string | undefined {
133 let firstKey: string | null = null;
134 for (const shell of readThreadShells()) {
135 if (shell.pinnedAt == null || shell.pinOrderKey == null) continue;
136 if (firstKey === null || shell.pinOrderKey < firstKey) firstKey = shell.pinOrderKey;
137 }
138 return pinOrderKeyBetween(null, firstKey) ?? undefined;
139}
140
141export class ThreadAutoSettleOptOutUnsupportedError extends Schema.TaggedError<ThreadAutoSettleOptOutUnsupportedError>()(
142 "ThreadAutoSettleOptOutUnsupportedError",
143 {
144 environmentId: EnvironmentId,
145 threadId: ThreadId,
146 },
147) {
148 override get message(): string {
149 return "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.";
150 }
151}
152
153export class ThreadPinningUnsupportedError extends Schema.TaggedError<ThreadPinningUnsupportedError>()(
154 "ThreadPinningUnsupportedError",
155 {
156 environmentId: EnvironmentId,
157 threadId: ThreadId,
158 },
159) {
160 override get message(): string {
161 return "This environment's server does not support pinning yet. Update the server to use Pin.";
162 }
163}
164
165export class ThreadPinReorderUnsupportedError extends Schema.TaggedError<ThreadPinReorderUnsupportedError>()(
166 "ThreadPinReorderUnsupportedError",
167 {
168 environmentId: EnvironmentId,
169 threadId: ThreadId,
170 },
171) {
172 override get message(): string {
173 return "This environment's server does not support reordering pinned threads yet. Update the server to reorder pins.";
174 }
175}
176
177export class ThreadActiveReorderUnsupportedError extends Schema.TaggedError<ThreadActiveReorderUnsupportedError>()(
178 "ThreadActiveReorderUnsupportedError",
179 {
180 environmentId: EnvironmentId,
181 threadId: ThreadId,
182 },
183) {
184 override get message(): string {
185 return "Update this environment's server to reorder active threads.";
186 }
187}
188
189export async function requestThreadUnpinConfirmation(input: {
190 enabled: boolean;
191 title: string;
192 confirm: ((message: string) => Promise<boolean>) | null;
193}) {
194 const { confirm } = input;
195 if (!input.enabled || confirm === null) {
196 return AsyncResult.success(true);
197 }
198
199 return settlePromise(() =>
200 confirm(
201 [
202 `Unpin thread "${input.title}"?`,
203 "This will move the thread out of your pinned section.",
204 ].join("\n"),
205 ),
206 );
207}
208
209/** Report navigation separately so a completed deletion can still finish worktree cleanup. */
210export async function navigateAfterThreadDeletion(navigate: () => Promise<void>) {
211 const result = await settlePromise(navigate);
212 if (result._tag === "Failure") {
213 const error = squashAtomCommandFailure(result);
214 toastManager.add(
215 stackedThreadToast({
216 type: "error",
217 title: "Thread deleted, but navigation failed",
218 description: error instanceof Error ? error.message : "An error occurred.",
219 }),
220 );
221 }
222}
223
224/**
225 * Marks a thread unread. Servers with visited tracking own the unread marker
226 * (thread.mark-unread rewinds the server-side visited watermark, syncing the
227 * marker to every device); older servers keep the browser-local marker.
228 */
229function useMarkThreadUnread() {
230 const markThreadUnreadMutation = useAtomCommand(threadEnvironment.markUnread, {
231 reportFailure: false,
232 });
233 const markThreadUnreadLocal = useUiStateStore((state) => state.markThreadUnread);
234 return useCallback(
235 (target: ScopedThreadRef) => {
236 if (readEnvironmentSupportsVisitedTracking(target.environmentId)) {
237 void markThreadUnreadMutation({
238 environmentId: target.environmentId,
239 input: { threadId: target.threadId },
240 });
241 return;
242 }
243 const thread = readThreadShell(target);
244 markThreadUnreadLocal(scopedThreadKey(target), thread?.latestRun?.completedAt);
245 },
246 [markThreadUnreadLocal, markThreadUnreadMutation],
247 );
248}
249
250/**
251 * Clears a thread's Woke marker by recording a visit at the wake time.
252 * Servers with visited tracking own the watermark (thread.visit keeps the
253 * later of the stored and supplied values, so this syncs to every device);
254 * older servers keep the browser-local watermark.
255 */
256export function useAcknowledgeThreadWoke() {
257 const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
258 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
259 return useCallback(
260 (target: ScopedThreadRef, wokeAt: string) => {
261 if (readEnvironmentSupportsVisitedTracking(target.environmentId)) {
262 void visitThreadMutation({
263 environmentId: target.environmentId,
264 input: { threadId: target.threadId, visitedAt: wokeAt },
265 });
266 return;
267 }
268 markThreadVisited(scopedThreadKey(target), wokeAt);
269 },
270 [markThreadVisited, visitThreadMutation],
271 );
272}
273
274function threadOperationFailure(target: ScopedThreadRef, organization = false) {
275 const permitted = organization
276 ? readThreadOrganizationPermission(target.environmentId)
277 : readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
278 return permitted
279 ? null
280 : AsyncResult.failure(
281 Cause.fail(
282 new EnvironmentAuthorizationError({
283 message: "This connection cannot change threads.",
284 requiredScope: AuthOrchestrationOperateScope,
285 }),
286 ),
287 );
288}
289
290export function useThreadActions() {
291 const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, {
292 reportFailure: false,
293 });
294 const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, {
295 reportFailure: false,
296 });
297 const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, {
298 reportFailure: false,
299 });
300 const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, {
301 organization: true,
302 reportFailure: false,
303 });
304 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
305 organization: true,
306 reportFailure: false,
307 });
308 const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, {
309 organization: true,
310 reportFailure: false,
311 });
312 const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, {
313 organization: true,
314 reportFailure: false,
315 });
316 const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, {
317 organization: true,
318 reportFailure: false,
319 });
320 const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, {
321 organization: true,
322 reportFailure: false,
323 });
324 const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, {
325 organization: true,
326 reportFailure: false,
327 });
328 const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, {
329 organization: true,
330 reportFailure: false,
331 });
332 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
333 organization: true,
334 reportFailure: false,
335 });
336 const markThreadUnread = useMarkThreadUnread();
337 const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession);
338 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
339 reportFailure: false,
340 });
341 const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, {
342 reportFailure: false,
343 });
344 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, {
345 reportFailure: false,
346 });
347 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
348 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
349 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
350 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
351 const clearProjectDraftThreadById = useComposerDraftStore(
352 (store) => store.clearProjectDraftThreadById,
353 );
354 const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState);
355 const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
356 const router = useRouter();
357 const handleNewThread = useNewThreadHandler();
358 // Keep a ref so archiveThread can call handleNewThread without appearing in
359 // its dependency array — handleNewThread is inherently unstable (depends on
360 // the projects list) and would otherwise cascade new references into every
361 // sidebar row via archiveThread → attemptArchiveThread.
362 const handleNewThreadRef = useRef(handleNewThread);
363 handleNewThreadRef.current = handleNewThread;
364
365 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
366 const thread = readThreadShell(target);
367 if (!thread) {
368 return null;
369 }
370 return {
371 thread,
372 threadRef: target,
373 };
374 }, []);
375 const getCurrentRouteThreadRef = useCallback(() => {
376 const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {};
377 return resolveThreadRouteRef(currentRouteParams);
378 }, [router]);
379
380 const unarchiveThread = useCallback(
381 async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => {
382 ThreadUndo.invalidate("archive", scopedThreadKey(target));
383 const result = await unarchiveThreadMutation({
384 environmentId: target.environmentId,
385 input: { threadId: target.threadId },
386 });
387 if (result._tag === "Failure") {
388 return result;
389 }
390 refreshArchivedThreadsForEnvironment(target.environmentId);
391 if (opts.navigate) {
392 return settlePromise(() =>
393 router.navigate({
394 to: "/$environmentId/$threadId",
395 params: buildThreadRouteParams(target),
396 }),
397 );
398 }
399 return result;
400 },
401 [router, unarchiveThreadMutation],
402 );
403
404 const archiveThread = useCallback(
405 async (
406 target: ScopedThreadRef,
407 opts: { onArchived?: () => void; snapshotSequence?: number } = {},
408 ) => {
409 const resolved = resolveThreadTarget(target);
410 if (!resolved) return AsyncResult.success(undefined);
411 const { thread, threadRef } = resolved;
412 if (!threadRuntimeCanArchive(thread.runtime)) {
413 return AsyncResult.failure(
414 Cause.fail(
415 new ThreadArchiveBlockedError({
416 environmentId: threadRef.environmentId,
417 threadId: threadRef.threadId,
418 }),
419 ),
420 );
421 }
422
423 const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef));
424 const archiveResult = await archiveThreadMutation({
425 environmentId: threadRef.environmentId,
426 input: {
427 threadId: threadRef.threadId,
428 ...(opts.snapshotSequence === undefined
429 ? {}
430 : { snapshotSequence: opts.snapshotSequence }),
431 },
432 });
433 if (archiveResult._tag === "Failure") {
434 action.finish();
435 return archiveResult;
436 }
437 const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() });
438 if (wokeAt !== null) {
439 markThreadVisited(scopedThreadKey(threadRef), wokeAt);
440 }
441 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
442 opts.onArchived?.();
443 const currentRouteThreadRef = getCurrentRouteThreadRef();
444 const shouldNavigateToDraft =
445 currentRouteThreadRef?.threadId === threadRef.threadId &&
446 currentRouteThreadRef.environmentId === threadRef.environmentId;
447 const navigationResult = shouldNavigateToDraft
448 ? await settlePromise(() =>
449 handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)),
450 )
451 : null;
452 showThreadUndoNotice({
453 action: "Archived",
454 claim: action,
455 // Undo also brings the reader back when archiving moved them to a draft.
456 undo: () =>
457 unarchiveThread(threadRef, {
458 navigate: navigationResult?._tag === "Success" && navigationResult.value !== null,
459 }),
460 failureTitle: "Failed to undo archive",
461 });
462
463 return navigationResult?._tag === "Failure" ? navigationResult : archiveResult;
464 },
465 [
466 archiveThreadMutation,
467 getCurrentRouteThreadRef,
468 markThreadVisited,
469 resolveThreadTarget,
470 unarchiveThread,
471 ],
472 );
473
474 const deleteThread = useCallback(
475 async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => {
476 const permissionFailure = threadOperationFailure(target);
477 if (permissionFailure) return permissionFailure;
478 const resolved = resolveThreadTarget(target);
479 if (!resolved) {
480 // Thread not in main store (e.g. archived thread) — dispatch delete directly.
481 const result = await deleteThreadMutation({
482 environmentId: target.environmentId,
483 input: { threadId: target.threadId },
484 });
485 if (result._tag === "Success") {
486 refreshArchivedThreadsForEnvironment(target.environmentId);
487 clearThreadPreviewState(target);
488 }
489 return result;
490 }
491 const { thread, threadRef } = resolved;
492 const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => {
493 const shell = readThreadShell(ref);
494 return shell === null ? [] : [shell];
495 });
496 const threadProject = readProject({
497 environmentId: threadRef.environmentId,
498 projectId: thread.projectId,
499 });
500 const deletedIds =
501 opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0
502 ? new Set<ThreadId>(
503 [...opts.deletedThreadKeys].flatMap((threadKey) => {
504 const ref = parseScopedThreadKey(threadKey);
505 return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : [];
506 }),
507 )
508 : undefined;
509 const survivingThreads =
510 deletedIds && deletedIds.size > 0
511 ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id))
512 : threads;
513 const orphanedWorktreePath = getOrphanedWorktreePathForThread(
514 survivingThreads,
515 threadRef.threadId,
516 );
517 const displayWorktreePath = orphanedWorktreePath
518 ? formatWorktreePathForDisplay(orphanedWorktreePath)
519 : null;
520 const environmentConfig = appAtomRegistry
521 .get(environmentServerConfigsAtom)
522 .get(threadRef.environmentId);
523 const localApi = readLocalApi();
524 let canDeleteWorktree = false;
525 if (
526 orphanedWorktreePath !== null &&
527 threadProject !== null &&
528 !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) &&
529 localApi
530 ) {
531 const sessionResult = await loadSessionState(threadRef.environmentId);
532 const permissionFailure = threadOperationFailure(threadRef);
533 if (permissionFailure) return permissionFailure;
534 canDeleteWorktree =
535 sessionResult._tag === "Success" &&
536 sessionGrantsScope(sessionResult.value, AuthSourceControlWriteScope);
537 }
538 let shouldDeleteWorktree = false;
539 const environmentSettings = environmentConfig?.settings;
540 const automaticWorktreeCleanup = environmentSettings
541 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete
542 : false;
543 if (canDeleteWorktree && localApi && !automaticWorktreeCleanup) {
544 const confirmationResult = await settlePromise(() =>
545 localApi.dialogs.confirm(
546 [
547 "This thread is the only one linked to this worktree:",
548 displayWorktreePath ?? orphanedWorktreePath,
549 "",
550 "Delete the worktree too?",
551 ].join("\n"),
552 { variant: "destructive" },
553 ),
554 );
555 if (confirmationResult._tag === "Failure") {
556 return confirmationResult;
557 }
558 shouldDeleteWorktree = confirmationResult.value;
559 }
560
561 if (thread.runtime !== null) {
562 await stopThreadSession({
563 environmentId: threadRef.environmentId,
564 input: { threadId: threadRef.threadId },
565 });
566 }
567
568 const deletedThreadIds = deletedIds ?? new Set<ThreadId>();
569 const currentRouteThreadRef = getCurrentRouteThreadRef();
570 const shouldNavigateToFallback =
571 currentRouteThreadRef?.threadId === threadRef.threadId &&
572 currentRouteThreadRef.environmentId === threadRef.environmentId;
573 const fallbackThreadId = getFallbackThreadIdAfterDelete({
574 threads,
575 deletedThreadId: threadRef.threadId,
576 deletedThreadIds,
577 sortOrder: sidebarThreadSortOrder,
578 });
579 const deleteResult = await deleteThreadMutation({
580 environmentId: threadRef.environmentId,
581 input: { threadId: threadRef.threadId },
582 });
583 if (deleteResult._tag === "Failure") {
584 return deleteResult;
585 }
586 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
587 releaseComposerDraftUploads(threadRef);
588 clearComposerDraftForThread(threadRef);
589 clearProjectDraftThreadById(
590 scopeProjectRef(threadRef.environmentId, thread.projectId),
591 threadRef,
592 );
593 clearTerminalUiState(threadRef);
594 clearThreadPreviewState(threadRef);
595
596 if (shouldNavigateToFallback) {
597 const fallbackThread = fallbackThreadId
598 ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId))
599 : null;
600 await navigateAfterThreadDeletion(() =>
601 fallbackThread
602 ? router.navigate({
603 to: "/$environmentId/$threadId",
604 params: buildThreadRouteParams(
605 scopeThreadRef(fallbackThread.environmentId, fallbackThread.id),
606 ),
607 replace: true,
608 })
609 : router.navigate({ to: "/", replace: true }),
610 );
611 }
612
613 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
614 return deleteResult;
615 }
616
617 const removeResult = readEnvironmentScope(
618 threadRef.environmentId,
619 AuthSourceControlWriteScope,
620 )
621 ? await removeWorktree({
622 environmentId: threadRef.environmentId,
623 input: {
624 cwd: threadProject.workspaceRoot,
625 path: orphanedWorktreePath,
626 force: true,
627 },
628 })
629 : AsyncResult.failure(
630 Cause.fail(
631 new EnvironmentAuthorizationError({
632 message: "This connection can no longer remove worktrees.",
633 requiredScope: AuthSourceControlWriteScope,
634 }),
635 ),
636 );
637 const refreshResult =
638 removeResult._tag === "Success"
639 ? await refreshVcsStatus({
640 environmentId: threadRef.environmentId,
641 input: { cwd: threadProject.workspaceRoot },
642 })
643 : null;
644 const cleanupFailure =
645 removeResult._tag === "Failure"
646 ? removeResult
647 : refreshResult?._tag === "Failure"
648 ? refreshResult
649 : null;
650 if (cleanupFailure) {
651 const removalFailed = removeResult._tag === "Failure";
652 const error = squashAtomCommandFailure(cleanupFailure);
653 const message = error instanceof Error ? error.message : "An error occurred.";
654 console.error("Worktree cleanup failed after thread deletion", {
655 threadId: threadRef.threadId,
656 projectCwd: threadProject.workspaceRoot,
657 worktreePath: orphanedWorktreePath,
658 error,
659 });
660 toastManager.add(
661 stackedThreadToast({
662 type: "error",
663 title: removalFailed
664 ? "Failed to delete worktree"
665 : "Worktree deleted, but Git status refresh failed",
666 description: removalFailed
667 ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`
668 : message,
669 }),
670 );
671 // The thread was deleted. Cleanup has its own toast; returning its
672 // failure would make callers incorrectly report a thread deletion error.
673 }
674 return deleteResult;
675 },
676 [
677 clearComposerDraftForThread,
678 clearProjectDraftThreadById,
679 clearTerminalUiState,
680 deleteThreadMutation,
681 getCurrentRouteThreadRef,
682 loadSessionState,
683 refreshVcsStatus,
684 removeWorktree,
685 router,
686 resolveThreadTarget,
687 sidebarThreadSortOrder,
688 stopThreadSession,
689 ],
690 );
691
692 const unsettleThread = useCallback(
693 async (target: ScopedThreadRef) => {
694 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
695 return AsyncResult.failure(
696 Cause.fail(
697 new ThreadSettlementUnsupportedError({
698 environmentId: target.environmentId,
699 threadId: target.threadId,
700 }),
701 ),
702 );
703 }
704 ThreadUndo.invalidate("settle", scopedThreadKey(target));
705 // reason "user" pins the thread active: auto-settle (PR merged /
706 // inactivity) stays suppressed until real activity clears the pin.
707 return unsettleThreadMutation({
708 environmentId: target.environmentId,
709 input: { threadId: target.threadId, reason: "user" },
710 });
711 },
712 [unsettleThreadMutation],
713 );
714
715 /** Turns automatic settlement (inactivity, merged PR) on or off for one thread. */
716 const setThreadAutoSettle = useCallback(
717 async (target: ScopedThreadRef, enabled: boolean) => {
718 if (!readEnvironmentSupportsAutoSettleOptOut(target.environmentId)) {
719 return AsyncResult.failure(
720 Cause.fail(
721 new ThreadAutoSettleOptOutUnsupportedError({
722 environmentId: target.environmentId,
723 threadId: target.threadId,
724 }),
725 ),
726 );
727 }
728 return setThreadAutoSettleMutation({
729 environmentId: target.environmentId,
730 input: { threadId: target.threadId, enabled },
731 });
732 },
733 [setThreadAutoSettleMutation],
734 );
735
736 const pinThread = useCallback(
737 async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => {
738 // Version skew: never send the command to a server that predates it.
739 if (!readEnvironmentSupportsPinning(target.environmentId)) {
740 return AsyncResult.failure(
741 Cause.fail(
742 new ThreadPinningUnsupportedError({
743 environmentId: target.environmentId,
744 threadId: target.threadId,
745 }),
746 ),
747 );
748 }
749 // Every pin path places the thread at the top of the arranged run:
750 // callers with a better anchor (the sidebar, which knows the displayed
751 // order) pass their own key; everyone else (chat header, context menus)
752 // gets the default so the same action never places differently.
753 // orderKey rides only to servers that decode it; pre-reorder servers
754 // get the bare pin they understand and the thread stays keyless.
755 const orderKey = readEnvironmentSupportsPinReorder(target.environmentId)
756 ? (opts.orderKey ?? topOfPinnedRunOrderKey())
757 : undefined;
758 ThreadUndo.invalidate("pin", scopedThreadKey(target));
759 return pinThreadMutation({
760 environmentId: target.environmentId,
761 input: {
762 threadId: target.threadId,
763 ...(orderKey !== undefined ? { orderKey } : {}),
764 },
765 });
766 },
767 [pinThreadMutation],
768 );
769
770 const unpinThread = useCallback(
771 async (target: ScopedThreadRef) => {
772 if (!readEnvironmentSupportsPinning(target.environmentId)) {
773 return AsyncResult.failure(
774 Cause.fail(
775 new ThreadPinningUnsupportedError({
776 environmentId: target.environmentId,
777 threadId: target.threadId,
778 }),
779 ),
780 );
781 }
782 const thread = readThreadShell(target);
783 const orderKey = thread?.pinOrderKey ?? undefined;
784 const action = ThreadUndo.begin("pin", scopedThreadKey(target));
785 const result = await unpinThreadMutation({
786 environmentId: target.environmentId,
787 input: { threadId: target.threadId },
788 });
789 if (result._tag === "Success" && action.isCurrent()) {
790 showThreadUndoNotice({
791 action: "Unpinned",
792 claim: action,
793 undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }),
794 failureTitle: "Failed to undo unpin",
795 });
796 } else {
797 action.finish();
798 }
799 return result;
800 },
801 [pinThread, unpinThreadMutation],
802 );
803
804 const confirmThreadSettlement = useCallback(async (targets: readonly ScopedThreadRef[]) => {
805 for (const target of targets) {
806 const permissionFailure = threadOperationFailure(target, true);
807 if (permissionFailure) return permissionFailure;
808 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
809 return AsyncResult.failure(Cause.fail(new ThreadSettlementUnsupportedError(target)));
810 }
811 }
812 if (targets.length === 0) return AsyncResult.success(false);
813 const offline = new Set(
814 targets.flatMap((target) =>
815 appAtomRegistry.get(hubConnection.organizationPermissionAtom(target.environmentId)) &&
816 Option.getOrNull(
817 AsyncResult.value(
818 appAtomRegistry.get(environmentCatalog.stateAtom(target.environmentId)),
819 ),
820 )?.phase !== "connected"
821 ? [target.environmentId]
822 : [],
823 ),
824 );
825 const metadata = new Map(
826 await Promise.all(
827 [...new Set(targets.map((target) => target.environmentId))].map(
828 async (environmentId) =>
829 [
830 environmentId,
831 offline.has(environmentId)
832 ? null
833 : await executeAtomQuery(
834 appAtomRegistry,
835 terminalEnvironment.metadataSnapshot({ environmentId, input: null }),
836 { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) },
837 ),
838 ] as const,
839 ),
840 ),
841 );
842 const resources = await Promise.all(
843 targets.map(async (target) => {
844 const terminals = metadata.get(target.environmentId)!;
845 const previews = offline.has(target.environmentId)
846 ? null
847 : await executeAtomQuery(
848 appAtomRegistry,
849 previewEnvironment.list({
850 environmentId: target.environmentId,
851 input: { threadId: target.threadId },
852 }),
853 { refresh: true, reportFailure: false, signal: AbortSignal.timeout(3_000) },
854 );
855 return {
856 runningCommands:
857 terminals?._tag === "Success"
858 ? terminals.value.some(
859 (terminal) =>
860 terminal.threadId === target.threadId && terminal.hasRunningSubprocess,
861 )
862 : null,
863 browserTabs: previews?._tag === "Success" ? previews.value.sessions.length > 0 : null,
864 };
865 }),
866 );
867 const warning = resolveThreadSettlementWarning({
868 runningCommands: resources.some((resource) => resource.runningCommands === null)
869 ? null
870 : resources.some((resource) => resource.runningCommands),
871 browserTabs: resources.some((resource) => resource.browserTabs === null)
872 ? null
873 : resources.some((resource) => resource.browserTabs),
874 });
875 if (warning === null) return AsyncResult.success(true);
876 const title =
877 targets.length === 1
878 ? `Settle "${readThreadShell(targets[0]!)?.title ?? "this chat"}"?`
879 : `Settle ${targets.length} chats?`;
880 const localApi = readLocalApi();
881 if (!localApi) return AsyncResult.failure(Cause.interrupt());
882 return settlePromise(() =>
883 localApi.dialogs.confirm(
884 `${title}\n${warning}${offline.size > 0 ? " Cleanup waits until the computer reconnects." : ""}`,
885 {
886 variant: "destructive",
887 },
888 ),
889 );
890 }, []);
891
892 const settleThread = useCallback(
893 async (target: ScopedThreadRef, options: { confirmed?: true } = {}) => {
894 const permissionFailure = threadOperationFailure(target, true);
895 if (permissionFailure) return permissionFailure;
896 // Version skew: never send the command to a server that predates it —
897 // the raw protocol rejection would read as a random failure.
898 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
899 return AsyncResult.failure(
900 Cause.fail(
901 new ThreadSettlementUnsupportedError({
902 environmentId: target.environmentId,
903 threadId: target.threadId,
904 }),
905 ),
906 );
907 }
908 if (readThreadShell(target)?.settledOverride === "settled") {
909 return AsyncResult.success(undefined);
910 }
911 if (!options.confirmed) {
912 const confirmation = await confirmThreadSettlement([target]);
913 if (confirmation._tag === "Failure") return confirmation;
914 if (!confirmation.value) return AsyncResult.failure(Cause.interrupt());
915 }
916 const currentPermissionFailure = threadOperationFailure(target, true);
917 if (currentPermissionFailure) return currentPermissionFailure;
918 const resolved = resolveThreadTarget(target);
919 const wokeAt = resolved
920 ? threadWokeAt(resolved.thread, { now: new Date().toISOString() })
921 : null;
922 // Settling also drops the pin and the snooze server-side, so Undo
923 // has to put those back as well.
924 const pinOrderKey = resolved?.thread.pinnedAt != null ? resolved.thread.pinOrderKey : null;
925 const wasPinned = resolved?.thread.pinnedAt != null;
926 const snoozedUntil = resolved?.thread.snoozedUntil ?? null;
927 // An older unpin/snooze Undo would re-pin or re-snooze, and the server
928 // treats either as a promotion that un-settles; settling supersedes them.
929 ThreadUndo.invalidate("pin", scopedThreadKey(target));
930 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
931 const action = ThreadUndo.begin("settle", scopedThreadKey(target));
932 const result = await settleThreadMutation({
933 environmentId: target.environmentId,
934 input: { threadId: target.threadId },
935 });
936 if (result._tag !== "Success") {
937 action.finish();
938 return result;
939 }
940 if (wokeAt !== null) {
941 markThreadVisited(scopedThreadKey(target), wokeAt);
942 }
943 showThreadUndoNotice({
944 action: "Settled",
945 claim: action,
946 undo: async () => {
947 const unsettled = await unsettleThread(target);
948 if (unsettled._tag !== "Success") return unsettled;
949 if (wasPinned) {
950 const pinned = await pinThread(
951 target,
952 pinOrderKey == null ? {} : { orderKey: pinOrderKey },
953 );
954 if (pinned._tag !== "Success") return pinned;
955 }
956 if (snoozedUntil !== null) {
957 return snoozeThreadMutation({
958 environmentId: target.environmentId,
959 input: { threadId: target.threadId, snoozedUntil },
960 });
961 }
962 return unsettled;
963 },
964 failureTitle: "Failed to undo settle",
965 });
966 return result;
967 },
968 [
969 confirmThreadSettlement,
970 markThreadVisited,
971 pinThread,
972 resolveThreadTarget,
973 settleThreadMutation,
974 snoozeThreadMutation,
975 unsettleThread,
976 ],
977 );
978
979 const confirmAndUnpinThread = useCallback(
980 async (target: ScopedThreadRef) => {
981 const permissionFailure = threadOperationFailure(target, true);
982 if (permissionFailure) return permissionFailure;
983 const localApi = readLocalApi();
984 const resolved = resolveThreadTarget(target);
985 const confirmationResult = await requestThreadUnpinConfirmation({
986 enabled: confirmThreadUnpin,
987 title: resolved?.thread.title ?? "this thread",
988 confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null,
989 });
990 if (confirmationResult._tag === "Failure") {
991 return confirmationResult;
992 }
993 if (!confirmationResult.value) {
994 return AsyncResult.success(undefined);
995 }
996 return unpinThread(target);
997 },
998 [confirmThreadUnpin, resolveThreadTarget, unpinThread],
999 );
1000
1001 const reorderPinnedThread = useCallback(
1002 async (target: ScopedThreadRef, orderKey: string) => {
1003 // Callers (the sidebar drag handler) only enable dragging on
1004 // reorder-capable environments; this guard covers races around
1005 // capability changes mid-drag.
1006 if (!readEnvironmentSupportsPinReorder(target.environmentId)) {
1007 return AsyncResult.failure(
1008 Cause.fail(
1009 new ThreadPinReorderUnsupportedError({
1010 environmentId: target.environmentId,
1011 threadId: target.threadId,
1012 }),
1013 ),
1014 );
1015 }
1016 ThreadUndo.invalidate("pin", scopedThreadKey(target));
1017 return reorderPinnedThreadMutation({
1018 environmentId: target.environmentId,
1019 input: { threadId: target.threadId, orderKey },
1020 });
1021 },
1022 [reorderPinnedThreadMutation],
1023 );
1024
1025 const reorderActiveThread = useCallback(
1026 async (target: ScopedThreadRef, orderKey: string) => {
1027 if (!readEnvironmentSupportsActiveReorder(target.environmentId)) {
1028 return AsyncResult.failure(
1029 Cause.fail(
1030 new ThreadActiveReorderUnsupportedError({
1031 environmentId: target.environmentId,
1032 threadId: target.threadId,
1033 }),
1034 ),
1035 );
1036 }
1037 return reorderActiveThreadMutation({
1038 environmentId: target.environmentId,
1039 input: { threadId: target.threadId, orderKey },
1040 });
1041 },
1042 [reorderActiveThreadMutation],
1043 );
1044
1045 const unsnoozeThread = useCallback(
1046 async (target: ScopedThreadRef) => {
1047 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
1048 return AsyncResult.failure(
1049 Cause.fail(
1050 new ThreadSnoozeUnsupportedError({
1051 environmentId: target.environmentId,
1052 threadId: target.threadId,
1053 }),
1054 ),
1055 );
1056 }
1057 ThreadUndo.invalidate("snooze", scopedThreadKey(target));
1058 return unsnoozeThreadMutation({
1059 environmentId: target.environmentId,
1060 input: { threadId: target.threadId, reason: "user" },
1061 });
1062 },
1063 [unsnoozeThreadMutation],
1064 );
1065
1066 const snoozeThread = useCallback(
1067 async (target: ScopedThreadRef, snoozedUntil: string) => {
1068 // Version skew: never send the command to a server that predates it.
1069 if (!readEnvironmentSupportsSnooze(target.environmentId)) {
1070 return AsyncResult.failure(
1071 Cause.fail(
1072 new ThreadSnoozeUnsupportedError({
1073 environmentId: target.environmentId,
1074 threadId: target.threadId,
1075 }),
1076 ),
1077 );
1078 }
1079 const resolved = resolveThreadTarget(target);
1080 // Blocked-on-you work and queued turns can't be snoozed away —
1081 // client-side twin of the server invariants so the UI rejects before
1082 // a round trip.
1083 if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) {
1084 return AsyncResult.failure(
1085 Cause.fail(
1086 new ThreadSnoozeBlockedError({
1087 environmentId: resolved.threadRef.environmentId,
1088 threadId: resolved.threadRef.threadId,
1089 }),
1090 ),
1091 );
1092 }
1093 const action = ThreadUndo.begin("snooze", scopedThreadKey(target));
1094 const result = await snoozeThreadMutation({
1095 environmentId: target.environmentId,
1096 input: { threadId: target.threadId, snoozedUntil },
1097 });
1098 if (result._tag !== "Success") {
1099 action.finish();
1100 return result;
1101 }
1102 // Snooze hides the row, so keep its confirmation in the sidebar.
1103 showThreadUndoNotice({
1104 action: "Snoozed",
1105 claim: action,
1106 undo: () => unsnoozeThread(target),
1107 failureTitle: "Failed to wake thread",
1108 });
1109 return result;
1110 },
1111 [resolveThreadTarget, snoozeThreadMutation, unsnoozeThread],
1112 );
1113
1114 const confirmAndDeleteThread = useCallback(
1115 async (target: ScopedThreadRef) => {
1116 const permissionFailure = threadOperationFailure(target);
1117 if (permissionFailure) return permissionFailure;
1118 const localApi = readLocalApi();
1119 const resolved = resolveThreadTarget(target);
1120
1121 if (confirmThreadDelete && localApi) {
1122 const title = resolved?.thread.title ?? "this thread";
1123 const confirmationResult = await settlePromise(() =>
1124 localApi.dialogs.confirm(
1125 [
1126 `Delete thread "${title}"?`,
1127 "This permanently clears conversation history for this thread.",
1128 ].join("\n"),
1129 { variant: "destructive" },
1130 ),
1131 );
1132 if (confirmationResult._tag === "Failure") {
1133 return confirmationResult;
1134 }
1135 if (!confirmationResult.value) {
1136 return AsyncResult.success(undefined);
1137 }
1138 }
1139
1140 return deleteThread(target);
1141 },
1142 [confirmThreadDelete, deleteThread, resolveThreadTarget],
1143 );
1144
1145 return useMemo(
1146 () => ({
1147 archiveThread,
1148 unarchiveThread,
1149 deleteThread,
1150 confirmAndDeleteThread,
1151 confirmThreadSettlement,
1152 settleThread,
1153 unsettleThread,
1154 snoozeThread,
1155 unsnoozeThread,
1156 pinThread,
1157 unpinThread,
1158 confirmAndUnpinThread,
1159 reorderPinnedThread,
1160 reorderActiveThread,
1161 markThreadUnread,
1162 setThreadAutoSettle,
1163 }),
1164 [
1165 archiveThread,
1166 confirmAndDeleteThread,
1167 confirmAndUnpinThread,
1168 confirmThreadSettlement,
1169 deleteThread,
1170 markThreadUnread,
1171 pinThread,
1172 reorderPinnedThread,
1173 reorderActiveThread,
1174 setThreadAutoSettle,
1175 settleThread,
1176 snoozeThread,
1177 unarchiveThread,
1178 unpinThread,
1179 unsettleThread,
1180 unsnoozeThread,
1181 ],
1182 );
1183}