| 1 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 2 | import { asArray } from "../lib/array"; |
| 3 | import { resolveTaskMonitorSession } from "../lib/taskMonitorNavigation"; |
| 4 | import { taskSessionIDFromPath, type SidebarImConnection } from "./sidebarImProjection"; |
| 5 | import { draftLandingTargetForTab } from "./draftLandingTarget"; |
| 6 | import type { useDesktopNavigation } from "./useDesktopNavigation"; |
| 7 | import type { WorkspaceNavigationPorts } from "./navigationOwner"; |
| 8 | import type { ControlResult, SessionMeta, TabMeta } from "../lib/types"; |
| 9 | import type { TopicShortcutEntry } from "../lib/topicShortcuts"; |
| 10 | import { useRef, useState, type Dispatch, type SetStateAction } from "react"; |
| 11 | import type { SessionRef } from "../lib/sessionRef"; |
| 12 | import type { ManualCreationObservation } from "../lib/manualCreationRequests"; |
| 13 | |
| 14 | const loadNavigationOwner = () => import("./navigationOwner"); |
| 15 | |
| 16 | export type SessionNavigationCommandsInput = { |
| 17 | activeTab: TabMeta | undefined; |
| 18 | showToast: (message: string, level: "error") => void; |
| 19 | closeTransientOverlays: () => void; |
| 20 | clearImDetail: () => void; |
| 21 | prepareBlankWorkspace: (workspaceRoot?: string) => void; |
| 22 | navigation: Pick<ReturnType<typeof useDesktopNavigation>, "enqueueNavigation" | "enqueueNavigationWithIntent" | "openRemoteProject">; |
| 23 | noteNavigationIntent: () => number; |
| 24 | beginNavigationSurface: (seq: number) => void; |
| 25 | settleNavigationSurface: (seq: number) => void; |
| 26 | isNavigationIntentCurrent: (seq: number) => boolean; |
| 27 | markProjectChanged: Dispatch<SetStateAction<number>>; |
| 28 | refreshTabMetas: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise<unknown>; |
| 29 | refreshHistoryView: () => void; |
| 30 | enterConversation: () => void; |
| 31 | pickWorkspace: WorkspaceNavigationPorts["pickWorkspace"]; |
| 32 | switchWorkspace: WorkspaceNavigationPorts["switchWorkspace"]; |
| 33 | ports: { |
| 34 | openTaskSessionForTab(tabId: string, taskId: string): Promise<ControlResult>; |
| 35 | listSessionsForTab(tabId: string): Promise<SessionMeta[]>; |
| 36 | }; |
| 37 | }; |
| 38 | |
| 39 | /** |
| 40 | * Owns explicit local creation and session navigation. Each creation is an |
| 41 | * independent mutation; only its selection follows the shared navigation |
| 42 | * epoch. Remote routing retains its own commands. Navigation never reads or |
| 43 | * waits for the retired workspace-draft store. |
| 44 | */ |
| 45 | export function useSessionNavigationCommands(input: SessionNavigationCommandsInput) { |
| 46 | const { activeTab, showToast, navigation, ports } = input; |
| 47 | const lastLocalTarget = useRef(draftLandingTargetForTab(activeTab)); |
| 48 | const [creation, setCreation] = useState<(ManualCreationObservation & { seq: number }) | null>(null); |
| 49 | if (activeTab && !activeTab.remote) lastLocalTarget.current = draftLandingTargetForTab(activeTab); |
| 50 | |
| 51 | const blankSessionTarget = useCommittedCommand(() => lastLocalTarget.current); |
| 52 | |
| 53 | const observeCreation = useCommittedCommand(async (attempt: ManualCreationObservation & { seq: number }, retry = false) => { |
| 54 | const { seq, request } = attempt; |
| 55 | const current = () => input.isNavigationIntentCurrent(seq) === true; |
| 56 | setCreation({ ...attempt, pending: true, failed: false }); |
| 57 | try { |
| 58 | const { createManualSession } = await import("../lib/manualCreationRequests"); |
| 59 | await createManualSession(request, { |
| 60 | retry, |
| 61 | isObservationCurrent: current, |
| 62 | onProgress: operation => { if (current()) setCreation({ ...attempt, operation, pending: true, failed: false }); }, |
| 63 | onSurfaceReady: async (reserved) => { |
| 64 | if (current()) await navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: reserved.ref }, seq); |
| 65 | }, |
| 66 | }); |
| 67 | input.markProjectChanged(value => value + 1); |
| 68 | } catch { |
| 69 | if (current()) setCreation(value => value?.seq === seq ? { ...value, failed: true } : value); |
| 70 | } finally { |
| 71 | if (current()) setCreation(value => value?.seq === seq ? { ...value, pending: false } : value); |
| 72 | input.settleNavigationSurface(seq); |
| 73 | } |
| 74 | }); |
| 75 | |
| 76 | const openBlankSession = useCommittedCommand(async (scope: string, workspaceRoot: string): Promise<void> => { |
| 77 | const seq = input.noteNavigationIntent(); |
| 78 | const operationId = `manual-${crypto.randomUUID()}`; |
| 79 | const targetRoot = scope === "project" ? workspaceRoot : ""; |
| 80 | lastLocalTarget.current = { scope: scope === "project" ? "project" : "global", workspaceRoot: targetRoot }; |
| 81 | // UI preferences use the actual directory; global navigation uses an empty wire root. |
| 82 | input.prepareBlankWorkspace(workspaceRoot); |
| 83 | input.enterConversation(); |
| 84 | input.beginNavigationSurface(seq); |
| 85 | // Creation is an explicit mutation, not a coalescible navigation request. |
| 86 | // Even if another click wins selection, this accepted operation survives. |
| 87 | await observeCreation({ seq, request: { operationId, workspaceId: "", scope, workspaceRoot: targetRoot }, pending: true, failed: false }); |
| 88 | }); |
| 89 | const retryCreation = useCommittedCommand(async () => { |
| 90 | if (creation && !creation.pending && input.isNavigationIntentCurrent(creation.seq)) await observeCreation(creation, true); |
| 91 | }); |
| 92 | |
| 93 | const handleNewTab = useCommittedCommand(async () => { |
| 94 | input.closeTransientOverlays(); |
| 95 | input.clearImDetail(); |
| 96 | if (activeTab?.remote) { |
| 97 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 98 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 99 | input.prepareBlankWorkspace(); |
| 100 | const outcome = await navigation.openRemoteProject(activeTab.remote, { newSession: true }); |
| 101 | if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); |
| 102 | return; |
| 103 | } |
| 104 | const target = blankSessionTarget(); |
| 105 | await openBlankSession(target.scope, target.workspaceRoot); |
| 106 | }); |
| 107 | |
| 108 | const handleOpenTopic = useCommittedCommand(async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<void> => { |
| 109 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 110 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 111 | input.closeTransientOverlays(); |
| 112 | input.clearImDetail(); |
| 113 | if (sessionPath?.startsWith("session-id:")) { |
| 114 | return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: { hostId: "local", sessionId: sessionPath.slice("session-id:".length) } }, navigationIntentSeq); |
| 115 | } |
| 116 | return navigation.enqueueNavigationWithIntent({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }, navigationIntentSeq); |
| 117 | }); |
| 118 | |
| 119 | const openSidebarImConnectionSession = useCommittedCommand(async (connection: SidebarImConnection): Promise<void> => { |
| 120 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 121 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 122 | input.clearImDetail(); |
| 123 | return navigation.enqueueNavigationWithIntent({ kind: "sidebar-im", connection }, navigationIntentSeq); |
| 124 | }); |
| 125 | |
| 126 | const onResumeSession = useCommittedCommand(async (session: SessionMeta): Promise<void> => { |
| 127 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 128 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 129 | return navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); |
| 130 | }); |
| 131 | const openCanonicalSession = useCommittedCommand(async (ref: SessionRef): Promise<void> => { |
| 132 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 133 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 134 | input.closeTransientOverlays(); |
| 135 | input.clearImDetail(); |
| 136 | return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref }, navigationIntentSeq); |
| 137 | }); |
| 138 | |
| 139 | const onRecoveryCreated = useCommittedCommand(() => { |
| 140 | input.markProjectChanged((value) => value + 1); |
| 141 | void input.refreshTabMetas(undefined, { afterMutation: true }); |
| 142 | }); |
| 143 | const onRecoveryLineageChanged = useCommittedCommand(() => { |
| 144 | input.markProjectChanged((value) => value + 1); |
| 145 | input.refreshHistoryView(); |
| 146 | }); |
| 147 | |
| 148 | const openTaskMonitorSession = useCommittedCommand(async (tabID: string, taskID: string): Promise<boolean> => { |
| 149 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 150 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return false; |
| 151 | // Claim the navigation epoch before the first bridge await. If the user |
| 152 | // switches tabs while the task/session lookup is pending, its completion is |
| 153 | // stale and must not enqueue a newer navigation request. |
| 154 | input.beginNavigationSurface(navigationIntentSeq); |
| 155 | let session: SessionMeta | null; |
| 156 | try { |
| 157 | session = await resolveTaskMonitorSession({ |
| 158 | tabID, |
| 159 | taskID, |
| 160 | intentSeq: navigationIntentSeq, |
| 161 | isIntentCurrent: input.isNavigationIntentCurrent, |
| 162 | openTaskSessionForTab: (sourceTabID, sourceTaskID) => ports.openTaskSessionForTab(sourceTabID, sourceTaskID), |
| 163 | listSessionsForTab: async (sourceTabID) => asArray(await ports.listSessionsForTab(sourceTabID)), |
| 164 | sessionIDFromPath: taskSessionIDFromPath, |
| 165 | }); |
| 166 | } catch (error) { |
| 167 | input.settleNavigationSurface(navigationIntentSeq); |
| 168 | throw error; |
| 169 | } |
| 170 | if (!session) { |
| 171 | input.settleNavigationSurface(navigationIntentSeq); |
| 172 | return false; |
| 173 | } |
| 174 | await navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); |
| 175 | return input.isNavigationIntentCurrent(navigationIntentSeq); |
| 176 | }); |
| 177 | |
| 178 | const refreshTabsAfterMutation = useCommittedCommand((latest: () => boolean) => ( |
| 179 | input.refreshTabMetas(latest, { afterMutation: true }) |
| 180 | )); |
| 181 | const switchFolder = useCommittedCommand(async (path?: string) => { |
| 182 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 183 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 184 | input.enterConversation(); |
| 185 | return loadNavigationOwner().then(({ navigateWorkspace }) => navigateWorkspace(path, { |
| 186 | claimIntent: input.noteNavigationIntent, |
| 187 | beginSurface: input.beginNavigationSurface, |
| 188 | isIntentCurrent: input.isNavigationIntentCurrent, |
| 189 | pickWorkspace: input.pickWorkspace, |
| 190 | switchWorkspace: input.switchWorkspace, |
| 191 | markProjectChanged: input.markProjectChanged, |
| 192 | refreshTabsAfterMutation, |
| 193 | maskTarget: input.settleNavigationSurface, |
| 194 | })); |
| 195 | }); |
| 196 | |
| 197 | const handleNavigateTopic = useCommittedCommand((entry: TopicShortcutEntry) => { |
| 198 | void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath); |
| 199 | }); |
| 200 | |
| 201 | return { |
| 202 | manualCreation: creation && input.isNavigationIntentCurrent(creation.seq) ? creation : null, |
| 203 | retryCreation, |
| 204 | openCanonicalSession, |
| 205 | openBlankSession, |
| 206 | handleNewTab, |
| 207 | handleOpenTopic, |
| 208 | openSidebarImConnectionSession, |
| 209 | onResumeSession, |
| 210 | onRecoveryCreated, |
| 211 | onRecoveryLineageChanged, |
| 212 | openTaskMonitorSession, |
| 213 | refreshTabsAfterMutation, |
| 214 | switchFolder, |
| 215 | handleNavigateTopic, |
| 216 | }; |
| 217 | } |
| 218 |