| 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 type { Dispatch, SetStateAction } from "react"; |
| 11 | import type { SessionRef } from "../lib/sessionRef"; |
| 12 | |
| 13 | const loadNavigationOwner = () => import("./navigationOwner"); |
| 14 | |
| 15 | export type SessionNavigationCommandsInput = { |
| 16 | activeTab: TabMeta | undefined; |
| 17 | showToast: (message: string, level: "error") => void; |
| 18 | closeTransientOverlays: () => void; |
| 19 | clearImDetail: () => void; |
| 20 | prepareBlankWorkspace: (workspaceRoot?: string) => void; |
| 21 | navigation: Pick<ReturnType<typeof useDesktopNavigation>, "enqueueNavigation" | "enqueueNavigationWithIntent" | "openRemoteProject">; |
| 22 | noteNavigationIntent: () => number; |
| 23 | beginNavigationSurface: (seq: number) => void; |
| 24 | settleNavigationSurface: (seq: number) => void; |
| 25 | isNavigationIntentCurrent: (seq: number) => boolean; |
| 26 | markProjectChanged: Dispatch<SetStateAction<number>>; |
| 27 | refreshTabMetas: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise<unknown>; |
| 28 | refreshHistoryView: () => void; |
| 29 | enterConversation: () => void; |
| 30 | pickWorkspace: WorkspaceNavigationPorts["pickWorkspace"]; |
| 31 | switchWorkspace: WorkspaceNavigationPorts["switchWorkspace"]; |
| 32 | draft: { |
| 33 | target?: { scope: string; workspaceRoot: string }; |
| 34 | open(scope: string, workspaceRoot: string): Promise<void>; |
| 35 | dismiss(): Promise<void> | void; |
| 36 | }; |
| 37 | ports: { |
| 38 | openTaskSessionForTab(tabId: string, taskId: string): Promise<ControlResult>; |
| 39 | listSessionsForTab(tabId: string): Promise<SessionMeta[]>; |
| 40 | }; |
| 41 | }; |
| 42 | |
| 43 | /** |
| 44 | * Owns the session-level navigation commands: local draft opening, |
| 45 | * topic/resume/sidebar-IM enqueues, new-tab routing (remote hosts reopen |
| 46 | * remotely), recovery refresh pairs, folder switching through the lazy |
| 47 | * navigation owner and the task-monitor session lookup with its |
| 48 | * navigation-intent fence. Formal-session navigation coalesces through the |
| 49 | * shared navigation epoch from useDesktopNavigation; local drafts use their |
| 50 | * own target/revision fencing. |
| 51 | */ |
| 52 | export function useSessionNavigationCommands(input: SessionNavigationCommandsInput) { |
| 53 | const { activeTab, showToast, navigation, ports } = input; |
| 54 | |
| 55 | const blankSessionTarget = useCommittedCommand(() => input.draft.target ?? draftLandingTargetForTab(activeTab)); |
| 56 | |
| 57 | const openBlankSession = useCommittedCommand((scope: string, workspaceRoot: string): Promise<void> => { |
| 58 | const targetRoot = scope === "project" ? workspaceRoot : ""; |
| 59 | // UI preferences use the actual directory; global navigation uses an empty wire root. |
| 60 | input.prepareBlankWorkspace(workspaceRoot); |
| 61 | input.enterConversation(); |
| 62 | return input.draft.open(scope, targetRoot); |
| 63 | }); |
| 64 | |
| 65 | const handleNewTab = useCommittedCommand(async () => { |
| 66 | input.closeTransientOverlays(); |
| 67 | input.clearImDetail(); |
| 68 | if (input.draft.target) { |
| 69 | const target = input.draft.target; |
| 70 | await openBlankSession(target.scope, target.workspaceRoot); |
| 71 | return; |
| 72 | } |
| 73 | if (activeTab?.remote) { |
| 74 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 75 | await input.draft.dismiss(); |
| 76 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 77 | input.prepareBlankWorkspace(); |
| 78 | const outcome = await navigation.openRemoteProject(activeTab.remote, { newSession: true }); |
| 79 | if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); |
| 80 | return; |
| 81 | } |
| 82 | const target = blankSessionTarget(); |
| 83 | await openBlankSession(target.scope, target.workspaceRoot); |
| 84 | }); |
| 85 | |
| 86 | const handleOpenTopic = useCommittedCommand(async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<void> => { |
| 87 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 88 | await input.draft.dismiss(); |
| 89 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 90 | input.closeTransientOverlays(); |
| 91 | input.clearImDetail(); |
| 92 | if (sessionPath?.startsWith("session-id:")) { |
| 93 | return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: { hostId: "local", sessionId: sessionPath.slice("session-id:".length) } }, navigationIntentSeq); |
| 94 | } |
| 95 | return navigation.enqueueNavigationWithIntent({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }, navigationIntentSeq); |
| 96 | }); |
| 97 | |
| 98 | const openSidebarImConnectionSession = useCommittedCommand(async (connection: SidebarImConnection): Promise<void> => { |
| 99 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 100 | await input.draft.dismiss(); |
| 101 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 102 | input.clearImDetail(); |
| 103 | return navigation.enqueueNavigationWithIntent({ kind: "sidebar-im", connection }, navigationIntentSeq); |
| 104 | }); |
| 105 | |
| 106 | const onResumeSession = useCommittedCommand(async (session: SessionMeta): Promise<void> => { |
| 107 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 108 | await input.draft.dismiss(); |
| 109 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 110 | return navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); |
| 111 | }); |
| 112 | const openCanonicalSession = useCommittedCommand(async (ref: SessionRef): Promise<void> => { |
| 113 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 114 | await input.draft.dismiss(); |
| 115 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 116 | input.closeTransientOverlays(); |
| 117 | input.clearImDetail(); |
| 118 | return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref }, navigationIntentSeq); |
| 119 | }); |
| 120 | |
| 121 | const onRecoveryCreated = useCommittedCommand(() => { |
| 122 | input.markProjectChanged((value) => value + 1); |
| 123 | void input.refreshTabMetas(undefined, { afterMutation: true }); |
| 124 | }); |
| 125 | const onRecoveryLineageChanged = useCommittedCommand(() => { |
| 126 | input.markProjectChanged((value) => value + 1); |
| 127 | input.refreshHistoryView(); |
| 128 | }); |
| 129 | |
| 130 | const openTaskMonitorSession = useCommittedCommand(async (tabID: string, taskID: string): Promise<boolean> => { |
| 131 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 132 | await input.draft.dismiss(); |
| 133 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return false; |
| 134 | // Claim the navigation epoch before the first bridge await. If the user |
| 135 | // switches tabs while the task/session lookup is pending, its completion is |
| 136 | // stale and must not enqueue a newer navigation request. |
| 137 | input.beginNavigationSurface(navigationIntentSeq); |
| 138 | let session: SessionMeta | null; |
| 139 | try { |
| 140 | session = await resolveTaskMonitorSession({ |
| 141 | tabID, |
| 142 | taskID, |
| 143 | intentSeq: navigationIntentSeq, |
| 144 | isIntentCurrent: input.isNavigationIntentCurrent, |
| 145 | openTaskSessionForTab: (sourceTabID, sourceTaskID) => ports.openTaskSessionForTab(sourceTabID, sourceTaskID), |
| 146 | listSessionsForTab: async (sourceTabID) => asArray(await ports.listSessionsForTab(sourceTabID)), |
| 147 | sessionIDFromPath: taskSessionIDFromPath, |
| 148 | }); |
| 149 | } catch (error) { |
| 150 | input.settleNavigationSurface(navigationIntentSeq); |
| 151 | throw error; |
| 152 | } |
| 153 | if (!session) { |
| 154 | input.settleNavigationSurface(navigationIntentSeq); |
| 155 | return false; |
| 156 | } |
| 157 | await navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); |
| 158 | return input.isNavigationIntentCurrent(navigationIntentSeq); |
| 159 | }); |
| 160 | |
| 161 | const refreshTabsAfterMutation = useCommittedCommand((latest: () => boolean) => ( |
| 162 | input.refreshTabMetas(latest, { afterMutation: true }) |
| 163 | )); |
| 164 | const switchFolder = useCommittedCommand(async (path?: string) => { |
| 165 | const navigationIntentSeq = input.noteNavigationIntent(); |
| 166 | await input.draft.dismiss(); |
| 167 | if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return; |
| 168 | input.enterConversation(); |
| 169 | return loadNavigationOwner().then(({ navigateWorkspace }) => navigateWorkspace(path, { |
| 170 | claimIntent: input.noteNavigationIntent, |
| 171 | beginSurface: input.beginNavigationSurface, |
| 172 | isIntentCurrent: input.isNavigationIntentCurrent, |
| 173 | pickWorkspace: input.pickWorkspace, |
| 174 | switchWorkspace: input.switchWorkspace, |
| 175 | markProjectChanged: input.markProjectChanged, |
| 176 | refreshTabsAfterMutation, |
| 177 | maskTarget: input.settleNavigationSurface, |
| 178 | })); |
| 179 | }); |
| 180 | |
| 181 | const handleNavigateTopic = useCommittedCommand((entry: TopicShortcutEntry) => { |
| 182 | void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath); |
| 183 | }); |
| 184 | |
| 185 | return { |
| 186 | openCanonicalSession, |
| 187 | openBlankSession, |
| 188 | handleNewTab, |
| 189 | handleOpenTopic, |
| 190 | openSidebarImConnectionSession, |
| 191 | onResumeSession, |
| 192 | onRecoveryCreated, |
| 193 | onRecoveryLineageChanged, |
| 194 | openTaskMonitorSession, |
| 195 | refreshTabsAfterMutation, |
| 196 | switchFolder, |
| 197 | handleNavigateTopic, |
| 198 | }; |
| 199 | } |
| 200 |