| 1 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 2 | import type { CancelOutcome } from "../lib/inboxCancel"; |
| 3 | import type { SessionResource, useSessionOperations } from "./useSessionOperations"; |
| 4 | |
| 5 | export type SessionControlCommandsInput = { |
| 6 | activeTabId: string | undefined; |
| 7 | resources: readonly SessionResource[]; |
| 8 | operations: ReturnType<typeof useSessionOperations>; |
| 9 | showToast: (message: string, level: "error") => void; |
| 10 | clearWorkspaceConflict: () => void; |
| 11 | ports: { |
| 12 | cancel(queuedItemIDs: string[]): Promise<CancelOutcome>; |
| 13 | cancelForTab(tabId: string, queuedItemIDs: string[]): Promise<CancelOutcome>; |
| 14 | acceptDelivery(tabId: string): Promise<unknown>; |
| 15 | disconnectRemote(hostId: string): Promise<unknown>; |
| 16 | cancelJobForTab(tabId: string, jobId: string): Promise<boolean>; |
| 17 | refreshBackgroundRuntimes(): Promise<void>; |
| 18 | }; |
| 19 | }; |
| 20 | |
| 21 | /** |
| 22 | * Owns the session control commands: active-turn cancel (capturing the |
| 23 | * committed source tab at the event boundary so presentation never reads the |
| 24 | * active-tab mirror mid-flight), delivery accept, remote host disconnect, |
| 25 | * workspace-conflict cancel and per-job runtime cancel through the session |
| 26 | * operations authority. |
| 27 | */ |
| 28 | export function useSessionControlCommands(input: SessionControlCommandsInput) { |
| 29 | const { activeTabId, resources, operations, showToast, ports } = input; |
| 30 | |
| 31 | const cancelRuntimeJob = useCommittedCommand(async (tabId: string, jobId: string): Promise<boolean> => { |
| 32 | const target = resources.find(resource => resource.tabId === tabId); |
| 33 | if (!target) return false; |
| 34 | const outcome = await operations(target, `runtime-cancel:${jobId}`, {}, async (_operationInput, authority) => |
| 35 | (await import("./sessionRuntimeOwner")).executeCancelRuntimeJob(target, jobId, { |
| 36 | cancelForTab: (sourceTabId, sourceJobId) => ports.cancelJobForTab(sourceTabId, sourceJobId), |
| 37 | refresh: () => ports.refreshBackgroundRuntimes(), |
| 38 | }, authority), |
| 39 | ); |
| 40 | if (outcome.status === "failed") { |
| 41 | showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); |
| 42 | return false; |
| 43 | } |
| 44 | return outcome.status === "completed" ? outcome.value : false; |
| 45 | }); |
| 46 | |
| 47 | const handleCancelActive = useCommittedCommand((queuedItemIDs: string[] = []) => { |
| 48 | const sourceTabId = activeTabId; |
| 49 | return sourceTabId ? ports.cancelForTab(sourceTabId, queuedItemIDs) : ports.cancel(queuedItemIDs); |
| 50 | }); |
| 51 | |
| 52 | const handleStopActive = useCommittedCommand(async () => { |
| 53 | const outcome = await handleCancelActive(); |
| 54 | if (outcome.error !== undefined) throw new Error(outcome.error); |
| 55 | }); |
| 56 | |
| 57 | // Capture the committed source tab at the event boundary. Presentation must |
| 58 | // never read the active-tab mirror while an async delivery operation is in flight. |
| 59 | const handleAcceptDelivery = useCommittedCommand(() => { |
| 60 | const sourceTabId = activeTabId; |
| 61 | if (!sourceTabId) return; |
| 62 | void ports.acceptDelivery(sourceTabId).catch((error) => { |
| 63 | console.warn("Failed to accept delivery", error); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | const handleDisconnectRemote = useCommittedCommand((hostId: string) => { |
| 68 | void ports.disconnectRemote(hostId).catch((error) => { |
| 69 | console.warn("Failed to disconnect remote host", error); |
| 70 | }); |
| 71 | }); |
| 72 | |
| 73 | const cancelWorkspaceConflict = useCommittedCommand(() => { |
| 74 | void handleCancelActive(); |
| 75 | input.clearWorkspaceConflict(); |
| 76 | }); |
| 77 | |
| 78 | return { |
| 79 | cancelRuntimeJob, |
| 80 | handleCancelActive, |
| 81 | handleStopActive, |
| 82 | handleAcceptDelivery, |
| 83 | handleDisconnectRemote, |
| 84 | cancelWorkspaceConflict, |
| 85 | }; |
| 86 | } |
| 87 |