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