| 1 | import { environmentCatalog } from "../connection/catalog"; |
| 2 | import { hubConnection } from "../connection/hub"; |
| 3 | import { |
| 4 | parseScopedThreadKey, |
| 5 | scopeProjectRef, |
| 6 | scopeThreadRef, |
| 7 | scopedThreadKey, |
| 8 | } from "@t3tools/client-runtime/environment"; |
| 9 | import { |
| 10 | executeAtomQuery, |
| 11 | settlePromise, |
| 12 | squashAtomCommandFailure, |
| 13 | } from "@t3tools/client-runtime/state/runtime"; |
| 14 | import { |
| 15 | canSnooze, |
| 16 | threadWokeAt, |
| 17 | resolveThreadSettlementWarning, |
| 18 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 19 | import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; |
| 20 | import { |
| 21 | AuthOrchestrationOperateScope, |
| 22 | AuthSourceControlWriteScope, |
| 23 | EnvironmentAuthorizationError, |
| 24 | EnvironmentId, |
| 25 | type ScopedThreadRef, |
| 26 | ThreadId, |
| 27 | sessionGrantsScope, |
| 28 | } from "@t3tools/contracts"; |
| 29 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 30 | import * as Cause from "effect/Cause"; |
| 31 | import * as Option from "effect/Option"; |
| 32 | import * as Schema from "effect/Schema"; |
| 33 | import { AsyncResult } from "effect/reactivity"; |
| 34 | import { useRouter } from "@tanstack/react-router"; |
| 35 | import { useCallback, useMemo, useRef } from "react"; |
| 36 | |
| 37 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 38 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 39 | import { |
| 40 | environmentSession, |
| 41 | readEnvironmentScope, |
| 42 | readThreadOrganizationPermission, |
| 43 | } from "../state/session"; |
| 44 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 45 | import { environmentServerConfigsAtom } from "../state/server"; |
| 46 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 47 | import { threadEnvironment } from "../state/threads"; |
| 48 | import { vcsEnvironment } from "../state/vcs"; |
| 49 | import { terminalEnvironment } from "../state/terminal"; |
| 50 | import { previewEnvironment } from "../state/preview"; |
| 51 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 52 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 53 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; |
| 54 | import { readLocalApi } from "../localApi"; |
| 55 | import { |
| 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"; |
| 68 | import { useUiStateStore } from "../uiStateStore"; |
| 69 | import { clearThreadPreviewState } from "../previewStateStore"; |
| 70 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 71 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 72 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 73 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 74 | import { useClientSettings } from "./useSettings"; |
| 75 | import * as ThreadUndo from "./threadUndo"; |
| 76 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; |
| 77 | import { useAtomCommand } from "../state/use-atom-command"; |
| 78 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 79 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 80 | |
| 81 | export 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 | |
| 93 | export 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 | |
| 105 | export 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 | |
| 117 | export 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. */ |
| 132 | function 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 | |
| 141 | export 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 | |
| 153 | export 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 | |
| 165 | export 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 | |
| 177 | export 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 | |
| 189 | export 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. */ |
| 210 | export 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 | */ |
| 229 | function 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 | */ |
| 256 | export 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 | |
| 274 | function 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 | |
| 290 | export 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 | } |