| 1 | import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; |
| 2 | import { useCommittedCommand } from "./useCommittedCommand"; |
| 3 | import { executeRemoteSend, executeComposerRuntime } from "../app-runtime/remoteComposerOwner"; |
| 4 | import type { SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations"; |
| 5 | import type { RemoteNavigationCommand } from "./remoteNavigationCommands"; |
| 6 | import { reconcileComposerProfile, type ComposerProfile, type ComposerProfilesByTab } from "./composerProfile"; |
| 7 | import type { GoalAction } from "./goalAction"; |
| 8 | import type { CollaborationMode, QualityFloor, RemoteTabRefView, ToolApprovalMode } from "./types"; |
| 9 | import type { RemoteSessionApi } from "./useRemoteSession"; |
| 10 | |
| 11 | type RemoteProfile = RemoteSessionApi["composerProfile"]; |
| 12 | |
| 13 | export function remoteRuntimeCommand(input: string): |
| 14 | | { method: "setModel" | "setEffort"; value: string } |
| 15 | | { method: "newSession" | "clearSession" } |
| 16 | | { method: "compact"; value: string } |
| 17 | | { method: "runManagementCommand"; rehydrate?: boolean } |
| 18 | | undefined { |
| 19 | const trimmed = input.trim(); |
| 20 | if (trimmed === "/new") return { method: "newSession" }; |
| 21 | if (trimmed === "/clear") return { method: "clearSession" }; |
| 22 | const match = /^\/(model|effort)\s+(\S+)$/.exec(trimmed); |
| 23 | if (match) return { method: match[1] === "model" ? "setModel" : "setEffort", value: match[2] }; |
| 24 | const verb = /^\/([^\s]+)/.exec(trimmed)?.[1]?.toLowerCase(); |
| 25 | if (verb === "compact") return { method: "compact", value: trimmed.slice("/compact".length).trim() }; |
| 26 | if (verb === "goal" && remoteGoalCommandStartsTurn(trimmed)) return undefined; |
| 27 | // These controller verbs are synchronous management operations: they emit |
| 28 | // notices or mutate session metadata but do not admit a conversational |
| 29 | // turn. Custom commands, skills, docs queries, and MCP prompts deliberately |
| 30 | // remain on the ordinary submit path because they can start model work. |
| 31 | const management = new Set([ |
| 32 | "context", "goal", "memory", "remember", "migrate", "migration", |
| 33 | "skill", "skills", "plugin", "plugins", "reload-cmd", "hooks", "mcp", |
| 34 | "provider", "tree", "branch", "switch", "rewind", |
| 35 | ]); |
| 36 | if (!verb || !management.has(verb)) return undefined; |
| 37 | return { method: "runManagementCommand", rehydrate: verb === "branch" || verb === "switch" || verb === "rewind" }; |
| 38 | } |
| 39 | |
| 40 | function remoteGoalCommandStartsTurn(input: string): boolean { |
| 41 | const args = input.trim().slice("/goal".length).trim().split(/\s+/).filter(Boolean); |
| 42 | const flags = new Set(["--strict", "--research", "--auto-research", "--deep", "--simple", "--no-research"]); |
| 43 | while (args.length > 0 && flags.has(args[0].toLowerCase())) args.shift(); |
| 44 | if (args.length === 0) return false; |
| 45 | const action = args.join(" ").toLowerCase(); |
| 46 | return !new Set(["status", "clear", "off", "stop", "done", "pause", "resume"]).has(action); |
| 47 | } |
| 48 | |
| 49 | export function useRemoteComposerSend( |
| 50 | activeRemote: RemoteTabRefView | undefined, |
| 51 | activeTabId: string | undefined, |
| 52 | collaborationMode: CollaborationMode, |
| 53 | goal: string, |
| 54 | session: RemoteSessionApi, |
| 55 | send: (displayText: string, submitText?: string) => Promise<void>, |
| 56 | applyGoal: (tabId: string, goal: string) => Promise<unknown>, |
| 57 | requestClear: () => void, |
| 58 | ownership: { target: SessionResource; operations: ReturnType<typeof useSessionOperations>; navigateRemote: RemoteNavigationCommand }, |
| 59 | ) { |
| 60 | const ports = { compact: session.compact, runManagementCommand: session.runManagementCommand, |
| 61 | setModel: session.setModel, setEffort: session.setEffort, |
| 62 | send, applyGoal, requestClear, newSession: ownership.navigateRemote }; |
| 63 | return useCommittedCommand(async (displayText: string, submitText = displayText): Promise<void> => { |
| 64 | const trimmed = (submitText || displayText).trim(); |
| 65 | const outcome = await ownership.operations(ownership.target, "send", { |
| 66 | tabId: activeTabId ?? "", remote: activeRemote, display: displayText, submit: submitText, commandText: trimmed, |
| 67 | command: remoteRuntimeCommand(trimmed), activateGoal: collaborationMode === "goal" && !goal.trim() && Boolean(trimmed), ports, |
| 68 | }, executeRemoteSend); |
| 69 | if (outcome.status === "failed") throw outcome.error; |
| 70 | }); |
| 71 | } |
| 72 | |
| 73 | export function useRemoteComposerProfileSync(options: { |
| 74 | activeTabId?: string; |
| 75 | sessionRoute?: string; |
| 76 | remote: boolean; |
| 77 | remoteProfile: RemoteProfile; |
| 78 | collaborationMode: CollaborationMode; |
| 79 | toolApprovalMode: ToolApprovalMode; |
| 80 | goal: string; |
| 81 | qualityFloor: QualityFloor; |
| 82 | pending: ComposerProfile["pending"]; |
| 83 | setProfiles: Dispatch<SetStateAction<ComposerProfilesByTab>>; |
| 84 | }): boolean { |
| 85 | const { activeTabId, sessionRoute, remote, remoteProfile, collaborationMode, toolApprovalMode, goal, qualityFloor, pending, setProfiles } = options; |
| 86 | // The last synced session route per tab. A tab switch A -> B -> A is not a |
| 87 | // route change for A; only A's own session rotation is, so the memory must |
| 88 | // be keyed by tab rather than hold the most recently active tab alone. |
| 89 | const routesRef = useRef(new Map<string, string>()); |
| 90 | useEffect(() => { |
| 91 | if (!activeTabId || !remote || !remoteProfile) return; |
| 92 | // One remote tab rotates through many sessions; a cached profile belongs |
| 93 | // to the session it was read from, so a route change adopts the backend |
| 94 | // snapshot instead of reconciling over the previous session's overrides. |
| 95 | const routes = routesRef.current; |
| 96 | const route = sessionRoute ?? ""; |
| 97 | const routeChanged = routes.has(activeTabId) && routes.get(activeTabId) !== route; |
| 98 | routes.set(activeTabId, route); |
| 99 | setProfiles((current) => { |
| 100 | // The profile table drops closed tabs on hydration; forget their routes |
| 101 | // at the next sync so the memory stays bounded to live tabs. Deleting |
| 102 | // absent ids is idempotent, which keeps this updater pure enough for a |
| 103 | // double invocation. |
| 104 | for (const id of routes.keys()) if (id !== activeTabId && !(id in current)) routes.delete(id); |
| 105 | const existing = routeChanged ? undefined : current[activeTabId]; |
| 106 | const backend: ComposerProfile = { |
| 107 | collaborationMode: remoteProfile.collaborationMode, |
| 108 | goalDraftMode: false, |
| 109 | toolApprovalMode: remoteProfile.toolApprovalMode, |
| 110 | goal: remoteProfile.goal, |
| 111 | qualityFloor: remoteProfile.qualityFloor, |
| 112 | pending: {}, |
| 113 | }; |
| 114 | const next = reconcileComposerProfile(existing, backend); |
| 115 | return existing === next ? current : { ...current, [activeTabId]: next }; |
| 116 | }); |
| 117 | }, [activeTabId, sessionRoute, remote, remoteProfile, setProfiles]); |
| 118 | |
| 119 | return !remote || Boolean(remoteProfile |
| 120 | && (pending.collaborationMode || collaborationMode === remoteProfile.collaborationMode) |
| 121 | && (pending.toolApprovalMode || toolApprovalMode === remoteProfile.toolApprovalMode) |
| 122 | && (pending.goal || goal === remoteProfile.goal) |
| 123 | && (pending.qualityFloor || qualityFloor === remoteProfile.qualityFloor)); |
| 124 | } |
| 125 | |
| 126 | export function useRemoteComposerRuntimeActions(options: { |
| 127 | target: SessionResource; |
| 128 | operations: ReturnType<typeof useSessionOperations>; |
| 129 | remote: boolean; |
| 130 | session: RemoteSessionApi; |
| 131 | runGoalAction: (action: GoalAction) => void; |
| 132 | pauseLocal: (tabId: string) => Promise<unknown>; |
| 133 | resumeLocal: (tabId: string) => Promise<unknown>; |
| 134 | setLocalEffort: (tabId: string, level: string) => Promise<void>; |
| 135 | showError: (message: string) => void; |
| 136 | }) { |
| 137 | const { target, operations, remote, session, runGoalAction, pauseLocal, resumeLocal, setLocalEffort, showError } = options; |
| 138 | const ports = { pauseGoal: session.pauseGoal, resumeGoal: session.resumeGoal, setEffort: session.setEffort, |
| 139 | pauseLocal, resumeLocal, effortLocal: setLocalEffort }; |
| 140 | const execute = useCommittedCommand(async (action: "pause" | "resume" | "effort", level?: string) => { |
| 141 | const outcome = await operations(target, action === "effort" ? "effort" : "goal-lifecycle", |
| 142 | { tabId: target.tabId, remote, action, level, ports }, executeComposerRuntime); |
| 143 | if (outcome.status === "failed") throw outcome.error; |
| 144 | }); |
| 145 | const pauseGoal = useCommittedCommand(() => runGoalAction(() => execute("pause"))); |
| 146 | const resumeGoal = useCommittedCommand(() => runGoalAction(() => execute("resume"))); |
| 147 | const report = useCommittedCommand((error: unknown) => showError(error instanceof Error ? error.message : String(error))); |
| 148 | const setEffort = useCommittedCommand((level: string) => { void execute("effort", level).catch(report); }); |
| 149 | return { pauseGoal, resumeGoal, setEffort }; |
| 150 | } |
| 151 |