| 1 | import { CommandCancelled } from "../lib/commandOutcome"; |
| 2 | import type { SessionRef } from "../lib/sessionRef"; |
| 3 | import type { RemoteTabOpenOptions, RemoteTabRefView, SessionMeta, TabMeta } from "../lib/types"; |
| 4 | import type { useAppRuntimeAdapter } from "./useAppRuntimeAdapter"; |
| 5 | import { isChannelSession, sidebarImSessionTarget, type SidebarImConnection } from "./sidebarImProjection"; |
| 6 | import type { SessionOperationAuthority } from "./useResourceOperations"; |
| 7 | import type { SessionPreparationView, SessionSelector } from "../generated/desktopContract.generated"; |
| 8 | |
| 9 | export type DesktopNavigationIntent = |
| 10 | | { kind: "canonical-session"; ref: SessionRef } |
| 11 | | { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string } |
| 12 | | { kind: "blank"; scope: string; workspaceRoot: string } |
| 13 | | { kind: "isolated-worktree"; workspaceRoot: string } |
| 14 | | { kind: "sidebar-im"; connection: SidebarImConnection } |
| 15 | | { kind: "resume-session"; session: SessionMeta } |
| 16 | | { kind: "remote-project"; remote: RemoteTabRefView; options: RemoteTabOpenOptions }; |
| 17 | type Runtime = ReturnType<typeof useAppRuntimeAdapter>; |
| 18 | export type DesktopNavigationPorts = Pick<Runtime["navigation"], |
| 19 | "isNavigationIntentCurrent" | "activateTopic" | "openCanonicalSession" |
| 20 | | "ensureBlankSurface" | "createIsolatedWorktree" | "registeredNavigationIntent" | "switchRemoteTab"> & |
| 21 | Pick<Runtime["sessionActions"], "openChannelSession" | "resumeSession"> & { |
| 22 | listTabs(): Promise<TabMeta[]>; |
| 23 | openRemoteProject(hostId: string, workspace: string, options: RemoteTabOpenOptions): Promise<TabMeta>; |
| 24 | applyTabs(tabs: TabMeta[]): void; |
| 25 | seedTab(tab: TabMeta): void; |
| 26 | topicAccepted?(intent: number): void; |
| 27 | reveal(): void; |
| 28 | projectChanged(): void; |
| 29 | closeHistory(): void; |
| 30 | listSessions(): Promise<SessionMeta[]>; |
| 31 | applyHistorySessions(sessions: SessionMeta[]): void; |
| 32 | prepareSession(selector: SessionSelector): Promise<SessionPreparationView>; |
| 33 | getSessionPreparation(operationId: string): Promise<SessionPreparationView>; |
| 34 | notice(notice: NavigationNotice): void; |
| 35 | }; |
| 36 | export type HistoricalPreparationSurface = { |
| 37 | session: SessionMeta; |
| 38 | operationId: string; |
| 39 | status: SessionPreparationView["status"]; |
| 40 | errorCode?: string; |
| 41 | retryable: boolean; |
| 42 | revision?: number; |
| 43 | isCurrent?: () => boolean; |
| 44 | }; |
| 45 | let historicalPreparation: HistoricalPreparationSurface | null = null; |
| 46 | const historicalPreparationListeners = new Set<() => void>(); |
| 47 | export const historicalPreparationSnapshot = () => historicalPreparation; |
| 48 | export const subscribeHistoricalPreparation = (listener: () => void) => { |
| 49 | historicalPreparationListeners.add(listener); |
| 50 | return () => historicalPreparationListeners.delete(listener); |
| 51 | }; |
| 52 | export const setHistoricalPreparation = (surface: HistoricalPreparationSurface | null) => { |
| 53 | historicalPreparation = surface; |
| 54 | historicalPreparationListeners.forEach(listener => listener()); |
| 55 | }; |
| 56 | export function reconcileHistoricalPreparation(expected: HistoricalPreparationSurface, view: SessionPreparationView) { |
| 57 | const current = historicalPreparation; |
| 58 | if (!current || current.operationId !== expected.operationId || current.session !== expected.session |
| 59 | || expected.isCurrent?.() === false || view.operationId !== expected.operationId |
| 60 | || view.revision < (current.revision ?? 0) || view.status === "ready") return; |
| 61 | setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode, |
| 62 | retryable: view.retryable, revision: view.revision }); |
| 63 | } |
| 64 | export type NavigationNotice = { |
| 65 | key: "history.failedOpenSession" | "history.missingWorkspaceRoot" | "history.failedOpenProject" | "sidebar.imWaiting" | "sidebar.imOpenFailed" |
| 66 | | "projectTree.worktreeCreated" | "projectTree.worktreeCreatedDirty"; |
| 67 | params?: Record<string, string>; |
| 68 | tone?: "error" | "warn" | "info"; |
| 69 | durationMs?: number; |
| 70 | } | { message: string; tone?: "error"; durationMs?: number }; |
| 71 | export type DesktopNavigationCapture = { |
| 72 | intent: DesktopNavigationIntent; |
| 73 | navigationIntentSeq: number; |
| 74 | ports: DesktopNavigationPorts; |
| 75 | }; |
| 76 | class InvalidSessionTarget extends Error { |
| 77 | constructor(readonly key: "history.failedOpenSession" | "history.missingWorkspaceRoot") { super(key); } |
| 78 | } |
| 79 | |
| 80 | const preparationTerminal = new Set(["ready", "blocked", "failed", "cancelled"]); |
| 81 | async function waitForPreparation(initial: SessionPreparationView, ports: DesktopNavigationPorts, checkpoint: () => void, changed: (view: SessionPreparationView) => void) { |
| 82 | let view = initial; |
| 83 | changed(view); |
| 84 | while (!preparationTerminal.has(view.status)) { |
| 85 | await new Promise(resolve => setTimeout(resolve, 250)); |
| 86 | checkpoint(); |
| 87 | const next = await ports.getSessionPreparation(view.operationId); |
| 88 | checkpoint(); |
| 89 | if (next.revision >= view.revision) { view = next; changed(view); } |
| 90 | } |
| 91 | if (view.status !== "ready" || !view.target) { |
| 92 | if (view.status === "blocked") throw new Error("Historical session is in use. Close the other instance and retry."); |
| 93 | if (view.status === "cancelled") throw new CommandCancelled("superseded"); |
| 94 | throw new Error(view.errorCode || "Historical session preparation failed."); |
| 95 | } |
| 96 | return view.target; |
| 97 | } |
| 98 | |
| 99 | /** One executor for topic, blank, IM, worktree and history activation. */ |
| 100 | export async function executeDesktopNavigation(input: DesktopNavigationCapture, authority: SessionOperationAuthority) { |
| 101 | const { navigationIntentSeq: seq, ports } = input; |
| 102 | let request = input.intent; |
| 103 | const checkpoint = () => { |
| 104 | authority.checkpoint(); |
| 105 | if (!ports.isNavigationIntentCurrent(seq)) throw new CommandCancelled("superseded"); |
| 106 | }; |
| 107 | const refresh = async () => { |
| 108 | const tabs = await ports.listTabs().catch(() => []); |
| 109 | checkpoint(); |
| 110 | ports.applyTabs(tabs); |
| 111 | }; |
| 112 | const openTopic = (scope: string, workspace: string, topic: string, path?: string) => |
| 113 | ports.activateTopic(scope, workspace, topic, path || "", seq); |
| 114 | const openBlank = (scope: string, workspace: string) => |
| 115 | ports.ensureBlankSurface(scope, scope === "project" ? workspace : "", seq); |
| 116 | checkpoint(); |
| 117 | setHistoricalPreparation(null); |
| 118 | try { |
| 119 | if (request.kind === "topic" && request.sessionPath?.startsWith("session-source:")) { |
| 120 | const { title, ...source } = JSON.parse(decodeURIComponent(request.sessionPath.slice("session-source:".length))); |
| 121 | request = { kind: "resume-session", session: { scope: request.scope, workspaceRoot: request.workspaceRoot, |
| 122 | topicId: request.topicId, title, path: source.path, source } as SessionMeta }; |
| 123 | } |
| 124 | if (request.kind === "canonical-session") { |
| 125 | await ports.openCanonicalSession(request.ref, seq); |
| 126 | checkpoint(); ports.closeHistory(); |
| 127 | await refresh(); checkpoint(); ports.reveal(); return; |
| 128 | } |
| 129 | if (request.kind === "remote-project") { |
| 130 | const token = await ports.registeredNavigationIntent(seq); |
| 131 | checkpoint(); |
| 132 | if (!token) throw new CommandCancelled("superseded"); |
| 133 | const tab = await ports.openRemoteProject(request.remote.hostId, request.remote.workspace, request.options); |
| 134 | checkpoint(); ports.seedTab(tab); |
| 135 | await ports.switchRemoteTab(tab, seq); |
| 136 | checkpoint(); ports.reveal(); |
| 137 | await refresh(); |
| 138 | return tab; |
| 139 | } |
| 140 | if (request.kind === "topic" || request.kind === "blank") { |
| 141 | const tab = request.kind === "topic" |
| 142 | ? await openTopic(request.scope, request.workspaceRoot, request.topicId, request.sessionPath) |
| 143 | : await openBlank(request.scope, request.workspaceRoot); |
| 144 | checkpoint(); ports.seedTab(tab); |
| 145 | if (request.kind === "topic") ports.topicAccepted?.(seq); |
| 146 | if (request.kind === "blank") ports.projectChanged(); |
| 147 | if (request.kind === "topic") { ports.reveal(); await refresh(); } |
| 148 | else { await refresh(); checkpoint(); ports.reveal(); } |
| 149 | return; |
| 150 | } |
| 151 | if (request.kind === "isolated-worktree") { |
| 152 | const result = await ports.createIsolatedWorktree(request.workspaceRoot, seq); |
| 153 | checkpoint(); ports.seedTab(result.tab); ports.projectChanged(); |
| 154 | await refresh(); checkpoint(); |
| 155 | ports.notice({ key: result.sourceDirty ? "projectTree.worktreeCreatedDirty" : "projectTree.worktreeCreated", |
| 156 | params: { branch: result.branch }, tone: result.sourceDirty ? "warn" : "info", durationMs: result.sourceDirty ? 7000 : 3500 }); |
| 157 | ports.reveal(); return; |
| 158 | } |
| 159 | if (request.kind === "sidebar-im") { |
| 160 | const { connection } = request; |
| 161 | const target = sidebarImSessionTarget(connection); |
| 162 | if (!target) { ports.notice({ key: "sidebar.imWaiting", params: { name: connection.title } }); return; } |
| 163 | let tab: TabMeta; |
| 164 | if (target.kind === "path") { |
| 165 | tab = await openBlank(connection.scope, connection.workspaceRoot); |
| 166 | checkpoint(); |
| 167 | if (connection.sessionSource === "auto") await ports.openChannelSession(target.value, tab.id, seq); |
| 168 | else await ports.resumeSession(target.value, tab.id, seq); |
| 169 | } else tab = await openTopic(connection.scope, connection.workspaceRoot, target.value); |
| 170 | checkpoint(); ports.seedTab(tab); |
| 171 | await refresh(); checkpoint(); ports.reveal(); ports.projectChanged(); return; |
| 172 | } |
| 173 | const { session } = request; |
| 174 | const scope = session.scope || (session.workspaceRoot ? "project" : "global"); |
| 175 | let tab: TabMeta; |
| 176 | if (isChannelSession(session)) { |
| 177 | tab = await openBlank(scope === "project" ? "project" : "global", session.workspaceRoot || ""); |
| 178 | checkpoint(); await ports.openChannelSession(session.path, tab.id, seq); |
| 179 | } else if (session.sessionId && (!session.hostId || session.hostId === "local")) { |
| 180 | tab = await openTopic(scope, session.workspaceRoot || "", session.topicId || `canonical-${session.sessionId}`, `session-id:${session.sessionId}`); |
| 181 | } else if (session.source) { |
| 182 | const prepared = await ports.prepareSession({ source: session.source, topicId: session.topicId || "" }); |
| 183 | checkpoint(); |
| 184 | const target = await waitForPreparation(prepared, ports, checkpoint, view => setHistoricalPreparation({ |
| 185 | session, operationId: view.operationId, status: view.status, errorCode: view.errorCode, retryable: view.retryable, |
| 186 | revision: view.revision, isCurrent: () => { try { checkpoint(); return true; } catch { return false; } }, |
| 187 | })); |
| 188 | setHistoricalPreparation(null); |
| 189 | tab = await openTopic(scope, session.workspaceRoot || "", session.topicId || `canonical-${target.sessionId}`, `session-id:${target.sessionId}`); |
| 190 | } else if (scope === "project" && session.workspaceRoot && session.topicId) { |
| 191 | tab = await openTopic("project", session.workspaceRoot, session.topicId, session.path); |
| 192 | } else if (scope === "global" && session.topicId) { |
| 193 | tab = await openTopic("global", "", session.topicId, session.path); |
| 194 | } else throw new InvalidSessionTarget(scope === "global" && !session.topicId |
| 195 | ? "history.failedOpenSession" : session.topicId ? "history.missingWorkspaceRoot" : "history.failedOpenSession"); |
| 196 | checkpoint(); ports.seedTab(tab); ports.closeHistory(); |
| 197 | if (input.intent.kind === "topic") ports.topicAccepted?.(seq); |
| 198 | ports.reveal(); await refresh(); |
| 199 | } catch (error) { |
| 200 | checkpoint(); |
| 201 | if (request.kind === "canonical-session") { |
| 202 | ports.notice({ message: error instanceof Error ? error.message : String(error), tone: "error" }); |
| 203 | return; |
| 204 | } |
| 205 | if (request.kind === "remote-project") throw error; |
| 206 | if (request.kind === "topic" || request.kind === "blank") { |
| 207 | ports.notice({ key: "history.failedOpenSession", tone: "error" }); |
| 208 | await refresh(); return; |
| 209 | } |
| 210 | if (request.kind === "isolated-worktree") { |
| 211 | ports.notice({ message: error instanceof Error ? error.message : String(error), tone: "error", durationMs: 6000 }); return; |
| 212 | } |
| 213 | if (request.kind === "sidebar-im") { ports.notice({ key: "sidebar.imOpenFailed", params: { name: request.connection.title } }); return; } |
| 214 | const history = await ports.listSessions().catch(() => null); |
| 215 | checkpoint(); |
| 216 | if (history) ports.applyHistorySessions(history); |
| 217 | const message = error instanceof Error ? error.message : String(error ?? ""); |
| 218 | if (/no such file|cannot find the file|file does not exist|session is pending cleanup|session .*not found/i.test(message)) return; |
| 219 | ports.closeHistory(); |
| 220 | const session = request.session; |
| 221 | const scope = session.scope || (session.workspaceRoot ? "project" : "global"); |
| 222 | if (scope === "project" && session.workspaceRoot) { |
| 223 | const parts = session.workspaceRoot.split(/[/\\]/).filter(Boolean); |
| 224 | ports.notice({ key: "history.failedOpenProject", params: { |
| 225 | name: parts[parts.length - 1] || session.workspaceRoot, path: session.workspaceRoot, |
| 226 | } }); |
| 227 | } else ports.notice(error instanceof InvalidSessionTarget ? { key: error.key } : { message }); |
| 228 | } |
| 229 | } |
| 230 |