返回 DeepSeek-Reasonix
desktopNavigationOwner.ts
根目录 / desktop / frontend / src / app-runtime / desktopNavigationOwner.ts
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
230 lines TYPESCRIPT