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